Update logic

This commit is contained in:
2026-04-18 22:01:34 +10:00
parent c34806a2e6
commit d933a99b8d
13 changed files with 683 additions and 498 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)
+14 -62
View File
@@ -1,4 +1,4 @@
# Generated by Django 5.2.12 on 2026-04-18 04:07
# Generated by Django 5.2.12 on 2026-04-18 11:31
import django.core.validators
import django.db.models.deletion
@@ -19,87 +19,39 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('account_id', models.CharField(blank=True, max_length=50)),
('base_currency', models.CharField(default='USD', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PriceCache',
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticker', models.CharField(max_length=20)),
('exchange', models.CharField(blank=True, default='', max_length=10)),
('price', models.DecimalField(decimal_places=6, max_digits=20)),
('currency', models.CharField(default='USD', max_length=10)),
('change_percent', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
('prev_close', models.DecimalField(blank=True, decimal_places=6, max_digits=20, null=True)),
('fetched_at', models.DateTimeField(auto_now_add=True)),
('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={
'indexes': [models.Index(fields=['ticker', 'exchange', 'fetched_at'], name='invest_pric_ticker_69ac3b_idx')],
},
),
migrations.CreateModel(
name='Report',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('content', models.TextField()),
('period_start', models.DateField()),
('period_end', models.DateField()),
('generated_at', models.DateTimeField(auto_now_add=True)),
('report_type', models.CharField(choices=[('WEEKLY', 'Weekly'), ('MANUAL', 'Manual')], default='WEEKLY', max_length=10)),
('valuation_snapshot', models.JSONField(default=dict, help_text='Snapshot of prices and holdings at time of report generation')),
('portfolio', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='invest.portfolio')),
],
options={
'ordering': ['-generated_at'],
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='Stock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticker', models.CharField(max_length=20)),
('exchange', models.CharField(blank=True, default='', help_text='Exchange code, e.g. NASDAQ, HKG. Empty = US market default.', max_length=10)),
('company_name', models.CharField(blank=True, max_length=200)),
('shares_held', models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
('avg_cost', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Weighted average cost per share in quote_currency', max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
('quote_currency', models.CharField(default='USD', max_length=10)),
('is_active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('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': ['ticker'],
'unique_together': {('portfolio', 'ticker', 'exchange')},
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tx_type', models.CharField(choices=[('BUY', 'Buy'), ('SELL', 'Sell')], max_length=4)),
('date', models.DateField()),
('price_per_share', models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
('shares', models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.000001'))])),
('fee', models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
('notes', models.TextField(blank=True)),
('source', models.CharField(default='manual', help_text="Origin of transaction: 'manual', 'ai', 'import'", max_length=20)),
('idempotency_key', models.CharField(blank=True, help_text='Unique key to prevent duplicate AI writes', max_length=100, null=True, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('stock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='invest.stock')),
],
options={
'ordering': ['date', 'created_at'],
'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')],
},
),
]
+21 -21
View File
@@ -16,19 +16,14 @@ class Portfolio(models.Model):
class Stock(models.Model):
"""
Current holdings snapshot for a portfolio.
Only stores stock_code and quantity - NO price stored.
Real-time prices fetched from Yahoo Finance on demand.
"""
"""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 code, e.g., 'NVDA', '9988.HK'")
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'))],
help_text="Number of shares held"
)
class Meta:
@@ -40,35 +35,40 @@ class Stock(models.Model):
class Transaction(models.Model):
"""
Historical buy/sell transactions.
Used to calculate avg_cost and derive P/L on demand.
"""
"""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, help_text="Stock ticker code, e.g., 'NVDA', '9988.HK'")
stock_code = models.CharField(max_length=20)
quantity = models.DecimalField(
max_digits=20,
decimal_places=6,
validators=[MinValueValidator(Decimal('0.000001'))],
help_text="Number of shares"
)
price_per_share = models.DecimalField(
max_digits=20,
decimal_places=6,
validators=[MinValueValidator(Decimal('0'))],
help_text="Price per share at time of transaction"
)
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} @ {self.price_per_share} on {self.date}"
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}"
+49
View File
@@ -9,6 +9,55 @@ class StockSerializer(serializers.ModelSerializer):
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)
+133 -189
View File
@@ -1,32 +1,28 @@
"""
Service layer for the invest app.
Real-time prices fetched from Yahoo Finance on demand using yfinance.
Prices fetched from Yahoo Finance on demand via yfinance.
No cost basis or P&L tracking.
"""
import logging
from decimal import Decimal
from datetime import datetime, date
from datetime import datetime
from typing import Optional
from django.db import transaction
from django.db.models import Sum
from .models import Portfolio, Stock, Transaction
from .models import Portfolio, Stock, PortfolioSnapshot
logger = logging.getLogger(__name__)
# In-memory cache for price failures (not persisted in DB)
# ---------------------------------------------------------------------------
# In-process price cache (5 min TTL)
# ---------------------------------------------------------------------------
_price_cache: dict[str, tuple[float, datetime]] = {}
_PRICE_CACHE_TTL_SECONDS = 300 # 5 minutes
_PRICE_CACHE_TTL_SECONDS = 300
# ---------------------------------------------------------------------------
# Price fetching via yfinance
# ---------------------------------------------------------------------------
def _get_yfinance_price(stock_code: str) -> Optional[float]:
"""
Fetch current price from Yahoo Finance using yfinance.
Returns None if fetch fails.
"""
try:
import yfinance as yf
ticker = yf.Ticker(stock_code)
@@ -40,210 +36,181 @@ def _get_yfinance_price(stock_code: str) -> Optional[float]:
def get_current_price(stock_code: str) -> Optional[float]:
"""
Get current price for a stock, with simple in-memory cache fallback.
"""
now = datetime.now()
cached = _price_cache.get(stock_code)
# Return cached price if fresh enough
if cached:
price, cached_at = cached
if (now - cached_at).total_seconds() < _PRICE_CACHE_TTL_SECONDS:
return price
# Fetch fresh price
price = _get_yfinance_price(stock_code)
if price is not None:
_price_cache[stock_code] = (price, now)
return price
# Fallback to stale cache if fetch failed
if cached:
logger.info("Using stale cached price for %s", stock_code)
return cached[0]
return None
# ---------------------------------------------------------------------------
# Holdings calculation from transactions
# Portfolio value (live prices, no cost tracking)
# ---------------------------------------------------------------------------
def calculate_holdings_from_transactions(portfolio: Portfolio) -> dict[str, dict]:
"""
Calculate current holdings (stock_code -> {quantity, avg_cost}) from transactions.
"""
holdings: dict[str, dict] = {}
txs = Transaction.objects.filter(portfolio=portfolio).order_by('date', 'created_at')
for tx in txs:
code = tx.stock_code
if code not in holdings:
holdings[code] = {
'quantity': Decimal('0'),
'total_cost': Decimal('0'),
}
if tx.action == Transaction.ACTION_BUY:
holdings[code]['quantity'] += tx.quantity
holdings[code]['total_cost'] += tx.quantity * tx.price_per_share
elif tx.action == Transaction.ACTION_SELL:
# Reduce quantity, proportionally reduce cost basis
if holdings[code]['quantity'] > 0:
sold_ratio = min(tx.quantity / holdings[code]['quantity'], Decimal('1'))
holdings[code]['total_cost'] *= (1 - sold_ratio)
holdings[code]['quantity'] -= tx.quantity
if holdings[code]['quantity'] < 0:
holdings[code]['quantity'] = Decimal('0')
# Calculate avg_cost
for code, data in holdings.items():
if data['quantity'] > 0:
data['avg_cost'] = float(data['total_cost'] / data['quantity'])
else:
data['avg_cost'] = 0.0
data['quantity'] = float(data['quantity'])
data['total_cost'] = float(data['total_cost'])
return holdings
# ---------------------------------------------------------------------------
# Portfolio holdings with real-time prices
# ---------------------------------------------------------------------------
def get_portfolio_holdings(portfolio: Portfolio) -> dict:
"""
Get portfolio holdings with real-time prices from Yahoo Finance.
Combines Stock snapshots with transaction history for avg_cost.
"""
# Get current Stock snapshot
stocks = {s.stock_code: float(s.quantity) for s in portfolio.stocks.all()}
# Get holdings from transactions
tx_holdings = calculate_holdings_from_transactions(portfolio)
# Merge: use Stock quantity as source of truth, tx_holdings for avg_cost
holdings_list = []
def get_portfolio_value(portfolio: Portfolio) -> dict:
"""Return live holdings with current prices and total value."""
holdings = []
total_value = Decimal('0')
total_cost = Decimal('0')
all_codes = set(stocks.keys()) | set(tx_holdings.keys())
for code in all_codes:
quantity = stocks.get(code, tx_holdings.get(code, {}).get('quantity', 0))
if isinstance(quantity, Decimal):
quantity = float(quantity)
if quantity <= 0:
continue
tx_data = tx_holdings.get(code, {})
avg_cost = tx_data.get('avg_cost', 0.0)
cost = Decimal(str(avg_cost)) * Decimal(str(quantity))
current_price = get_current_price(code)
if current_price is None:
current_price = 0.0
value = Decimal(str(current_price)) * Decimal(str(quantity))
pnl = value - cost
pnl_pct = (float(pnl / cost * 100) if cost > 0 else 0.0) if cost != 0 else 0.0
holdings_list.append({
'stock_code': code,
'quantity': quantity,
'avg_cost': avg_cost,
'current_price': current_price,
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),
'unrealized_pnl': float(pnl),
'unrealized_pnl_pct': round(pnl_pct, 2),
})
total_value += value
total_cost += cost
total_pnl = total_value - total_cost
total_pnl_pct = (float(total_pnl / total_cost * 100) if total_cost > 0 else 0.0) if total_cost != 0 else 0.0
return {
'portfolio_id': portfolio.id,
'portfolio_name': portfolio.name,
'holdings': holdings_list,
'holdings': holdings,
'total_value': float(total_value),
'total_cost': float(total_cost),
'total_pnl': float(total_pnl),
'total_pnl_pct': round(total_pnl_pct, 2),
}
# ---------------------------------------------------------------------------
# AI update - sync holdings from AI
# 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
portfolio_rows = []
for portfolio in 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,
})
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/40', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50/40', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50/40', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50/40', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50/40', '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
def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict:
"""
AI updates portfolio holdings.
Creates/updates Stock records and corresponding BUY transactions.
"""Update Stock records. No cost/price tracking."""
from django.db import transaction as db_transaction
Args:
portfolio: Portfolio to update
holdings: List of {"stock_code": str, "quantity": float, "avg_cost": float}
reset: If True, clear existing holdings first
"""
results = []
with transaction.atomic():
with db_transaction.atomic():
if reset:
# Delete existing stocks and transactions
portfolio.stocks.all().delete()
portfolio.transactions.all().delete()
for item in holdings:
stock_code = item['stock_code']
quantity = Decimal(str(item['quantity']))
avg_cost = Decimal(str(item.get('avg_cost', 0)))
# Create or update Stock
stock, created = Stock.objects.update_or_create(
portfolio=portfolio,
stock_code=stock_code,
defaults={'quantity': quantity}
defaults={'quantity': quantity},
)
# Create a BUY transaction to record the holding
if quantity > 0 and avg_cost > 0:
# Check if transaction already exists for this stock_code with same avg_cost
# (to avoid duplicates on re-runs)
existing_tx = Transaction.objects.filter(
portfolio=portfolio,
stock_code=stock_code,
action=Transaction.ACTION_BUY,
price_per_share=avg_cost,
).first()
if not existing_tx:
Transaction.objects.create(
portfolio=portfolio,
action=Transaction.ACTION_BUY,
stock_code=stock_code,
quantity=quantity,
price_per_share=avg_cost,
date=date.today(),
)
tx_status = 'created'
else:
tx_status = 'skipped_existing'
results.append({
'stock_code': stock_code,
'quantity': float(quantity),
'avg_cost': float(avg_cost),
'stock_created': created,
'tx_status': tx_status if quantity > 0 and avg_cost > 0 else 'skipped',
'created': created,
})
return {
@@ -252,26 +219,3 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
'reset': reset,
'results': results,
}
# ---------------------------------------------------------------------------
# Transaction CRUD
# ---------------------------------------------------------------------------
def add_transaction(
portfolio: Portfolio,
action: str,
stock_code: str,
quantity: Decimal,
price_per_share: Decimal,
date: date,
) -> Transaction:
"""Add a buy or sell transaction."""
return Transaction.objects.create(
portfolio=portfolio,
action=action.upper(),
stock_code=stock_code.upper(),
quantity=quantity,
price_per_share=price_per_share,
date=date,
)
+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)
+18 -38
View File
@@ -1,66 +1,47 @@
"""Template views for the invest app."""
import json
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_holdings
from .services import get_portfolio_value, get_weekly_overview, get_all_holdings
logger = logging.getLogger(__name__)
def dashboard(request):
"""Landing page: list of portfolios with key metrics."""
portfolios = Portfolio.objects.prefetch_related('stocks').order_by('name')
summaries = []
"""Landing page: weekly snapshot overview + per-portfolio table."""
overview = get_weekly_overview()
for portfolio in portfolios:
try:
s = get_portfolio_holdings(portfolio)
# Simplify for dashboard display
summaries.append({
'portfolio': {'id': portfolio.id, 'name': portfolio.name},
'holdings': s['holdings'],
'total_value': s['total_value'],
'total_cost': s['total_cost'],
'total_pnl': s['total_pnl'],
'total_pnl_pct': s['total_pnl_pct'],
})
except Exception as exc:
logger.warning("get_portfolio_holdings failed for %s: %s", portfolio.id, exc)
summaries.append({
'portfolio': {'id': portfolio.id, 'name': portfolio.name},
'holdings': [],
'total_value': 0,
'total_cost': 0,
'total_pnl': 0,
'total_pnl_pct': 0,
})
# 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:]}"
return render(request, 'invest/dashboard.html', {'summaries': summaries})
return render(request, 'invest/dashboard.html', {
'overview': overview,
'fy_label': fy_label,
'all_holdings': get_all_holdings(),
})
def portfolio_detail(request, pk):
"""Portfolio detail view with holdings table."""
"""Portfolio detail: live holdings, no cost/P&L."""
portfolio = get_object_or_404(Portfolio, pk=pk)
try:
summary = get_portfolio_holdings(portfolio)
summary = get_portfolio_value(portfolio)
except Exception as exc:
logger.error("get_portfolio_holdings failed for %s: %s", pk, exc)
logger.error("get_portfolio_value failed for %s: %s", pk, exc)
summary = {
'portfolio': {'id': portfolio.id, 'name': portfolio.name},
'portfolio_id': portfolio.id,
'portfolio_name': portfolio.name,
'holdings': [],
'total_value': 0,
'total_cost': 0,
'total_pnl': 0,
'total_pnl_pct': 0,
}
return render(request, 'invest/portfolio_detail.html', {
'portfolio': portfolio,
'summary': summary,
'summary_json': json.dumps(summary),
})
@@ -68,7 +49,6 @@ 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,
+1 -1
View File
@@ -11,7 +11,7 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-100 min-h-screen">
<body class="bg-stone-100 min-h-screen">
<!-- Navigation -->
<nav class="bg-gray-900 text-white px-6 py-4 shadow-lg">
+183 -85
View File
@@ -4,101 +4,199 @@
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Investment Portfolios</h1>
<div class="mb-2">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} SNAPSHOT</p>
</div>
{% if not summaries %}
<div class="bg-white rounded-xl shadow p-12 text-center">
<i class="fas fa-chart-pie text-gray-300 text-5xl mb-4"></i>
<p class="text-gray-500 text-lg">No portfolios yet.</p>
<p class="text-gray-400 mt-1">Use the API to create a portfolio and add holdings.</p>
<!-- ── 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>
<!-- ── Portfolio value table ─────────────────────────────────── -->
{% if overview.portfolio_rows %}
<div class="bg-white rounded-lg shadow-sm overflow-hidden mb-4">
<div class="px-6 py-4 border-b border-stone-100">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">Portfolio Breakdown</p>
</div>
<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">Portfolio</th>
<th class="px-6 py-3 text-right">This Week</th>
<th class="px-6 py-3 text-right">Last Week</th>
<th class="px-6 py-3 text-right">Change</th>
<th class="px-6 py-3 text-right">Change %</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-50">
{% for row in overview.portfolio_rows %}
<tr class="hover:bg-stone-50">
<td class="px-6 py-4 font-semibold text-stone-900">{{ row.portfolio.name }}</td>
<td class="px-6 py-4 text-right text-stone-700">
{% if row.this_week_value is not None %}${{ row.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</td>
<td class="px-6 py-4 text-right text-stone-400">
{% if row.last_week_value is not None %}${{ row.last_week_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</td>
<td class="px-6 py-4 text-right font-medium
{% if row.change is not None %}{% if row.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}{% else %}text-stone-300{% endif %}">
{% if row.change is not None %}{% if row.change >= 0 %}+{% endif %}${{ row.change|floatformat:0 }}{% else %}—{% endif %}
</td>
<td class="px-6 py-4 text-right font-medium
{% if row.change_pct is not None %}{% if row.change_pct >= 0 %}text-green-700{% else %}text-red-600{% endif %}{% else %}text-stone-300{% endif %}">
{% if row.change_pct is not None %}{% if row.change_pct >= 0 %}+{% endif %}{{ row.change_pct|floatformat:2 }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
{% if overview.this_week_total is not None %}
<tfoot>
<tr class="border-t-2 border-stone-200 bg-stone-50">
<td class="px-6 py-3 font-semibold text-stone-700 text-xs uppercase tracking-wide">Total</td>
<td class="px-6 py-3 text-right font-bold text-stone-900">${{ overview.this_week_total|floatformat:0 }}</td>
<td class="px-6 py-3 text-right text-stone-400">
{% if overview.last_week_total is not None %}${{ overview.last_week_total|floatformat:0 }}{% else %}—{% endif %}
</td>
<td class="px-6 py-3 text-right font-bold
{% if overview.week_gain is not None %}{% if overview.week_gain >= 0 %}text-green-700{% else %}text-red-600{% endif %}{% endif %}">
{% if overview.week_gain is not None %}{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0 }}{% else %}—{% endif %}
</td>
<td class="px-6 py-3 text-right font-bold
{% if overview.week_change_pct is not None %}{% if overview.week_change_pct >= 0 %}text-green-700{% else %}text-red-600{% endif %}{% endif %}">
{% if overview.week_change_pct is not None %}{% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}%{% else %}—{% endif %}
</td>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% else %}
<div class="bg-white rounded-lg shadow-sm p-12 text-center">
<p class="text-stone-400 text-lg">No portfolios yet.</p>
<p class="text-stone-300 text-sm mt-1">Snapshots are captured every Saturday at 08:00.</p>
</div>
{% endif %}
{% for s in summaries %}
{% with p=s.portfolio %}
<div class="bg-white rounded-xl shadow mb-6 overflow-hidden">
<!-- Portfolio header -->
<div class="bg-gray-900 px-6 py-4 flex items-center justify-between">
<div>
<a href="{% url 'invest-portfolio-detail' p.id %}" class="text-white text-xl font-semibold hover:text-green-300 transition">
{{ p.name }}
</a>
</div>
<a href="{% url 'invest-portfolio-detail' p.id %}" class="text-gray-400 hover:text-white text-sm">
View Details <i class="fas fa-chevron-right ml-1"></i>
</a>
</div>
<!-- ── Hint ──────────────────────────────────────────────────── -->
<p class="text-xs text-stone-400 text-center mt-2">
Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency
</p>
<!-- Summary metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-0 divide-x divide-gray-100">
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Value</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ s.total_value|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Cost Basis</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ s.total_cost|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">P&L</p>
<p class="text-xl font-bold mt-1 {% if s.total_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if s.total_pnl >= 0 %}+{% endif %}{{ s.total_pnl|floatformat:2 }}
</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Return</p>
<p class="text-xl font-bold mt-1 {% if s.total_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if s.total_pnl_pct >= 0 %}+{% endif %}{{ s.total_pnl_pct|floatformat:2 }}%
</p>
</div>
</div>
<!-- ── Unified live holdings ─────────────────────────────────── -->
{% if all_holdings %}
<div class="mt-6 mb-2">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">Live Holdings</p>
</div>
<!-- Holdings table -->
{% if s.holdings %}
<div class="px-6 pb-4">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-gray-100">
<th class="pb-2 text-left font-medium">Stock</th>
<th class="pb-2 text-right font-medium">Qty</th>
<th class="pb-2 text-right font-medium">Avg Cost</th>
<th class="pb-2 text-right font-medium">Current</th>
<th class="pb-2 text-right font-medium">Value</th>
<th class="pb-2 text-right font-medium">P&L</th>
<th class="pb-2 text-right font-medium">P&L %</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for stock in s.holdings %}
<tr class="hover:bg-gray-50">
<td class="py-2">
<span class="font-semibold text-gray-900">{{ stock.stock_code }}</span>
<!-- Legend badges -->
<div class="flex flex-wrap gap-2 mb-3">
{% for group in all_holdings %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ group.colors.badge }}">
{{ group.portfolio.name }}
&nbsp;·&nbsp;${{ group.total_value|floatformat:0 }}
</span>
{% endfor %}
</div>
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<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">Portfolio</th>
<th class="px-6 py-3 text-left">Stock</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">Value</th>
</tr>
</thead>
<tbody>
{% for group in all_holdings %}
{% for stock in group.holdings %}
<tr class="border-b border-stone-50 hover:bg-stone-50 {{ group.colors.row }}">
{% if forloop.first %}
<td class="px-6 py-3 font-semibold" rowspan="{{ group.holdings|length }}">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">
{{ group.portfolio.name }}
</span>
</td>
<td class="py-2 text-right text-gray-700">{{ stock.quantity|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.avg_cost|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">
{% if stock.current_price > 0 %}
$ {{ stock.current_price|floatformat:2 }}
{% else %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.current_value|floatformat:2 }}</td>
<td class="py-2 text-right {% if stock.unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }}
</td>
<td class="py-2 text-right {% if stock.unrealized_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
{% endif %}
<td class="px-6 py-3 font-medium text-stone-900">{{ stock.stock_code }}</td>
<td class="px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:2 }}</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">${{ stock.current_value|floatformat:0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Sub-total row -->
<tr class="border-b-2 {{ group.colors.border }}">
<td class="px-6 py-2 text-xs text-right text-stone-400 uppercase tracking-wide font-semibold" colspan="4">
{{ group.portfolio.name }} total
</td>
<td class="px-6 py-2 text-right font-bold text-stone-700">${{ group.total_value|floatformat:0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endwith %}
{% endfor %}
{% endif %}
{% endblock %}
+53 -82
View File
@@ -4,96 +4,67 @@
{% block title %}{{ portfolio.name }} - Portfolio{% 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 class="mb-4">
<a href="{% url 'invest-dashboard' %}" class="text-stone-400 hover:text-stone-700 text-sm">← Dashboard</a>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden">
<!-- Portfolio header -->
<div class="bg-gray-900 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-white text-2xl font-bold">{{ portfolio.name }}</h1>
<p class="text-gray-400 text-sm mt-1">Portfolio #{{ portfolio.id }}</p>
</div>
<div class="text-right">
<a href="{% url 'invest-portfolio-transactions' portfolio.id %}" class="text-gray-400 hover:text-white text-sm">
View Transactions <i class="fas fa-chevron-right ml-1"></i>
</a>
</div>
</div>
<div class="mb-2">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ portfolio.name }}</p>
</div>
<!-- Summary metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-0 divide-x divide-gray-100 border-b border-gray-100">
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Value</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ summary.total_value|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Cost Basis</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ summary.total_cost|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">P&L</p>
<p class="text-xl font-bold mt-1 {% if summary.total_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if summary.total_pnl >= 0 %}+{% endif %}{{ summary.total_pnl|floatformat:2 }}
</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Return</p>
<p class="text-xl font-bold mt-1 {% if summary.total_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if summary.total_pnl_pct >= 0 %}+{% endif %}{{ summary.total_pnl_pct|floatformat:2 }}%
</p>
</div>
<!-- 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 -->
<!-- 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 %}
<div class="px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Holdings</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-gray-100">
<th class="pb-2 text-left font-medium">Stock</th>
<th class="pb-2 text-right font-medium">Qty</th>
<th class="pb-2 text-right font-medium">Avg Cost</th>
<th class="pb-2 text-right font-medium">Current</th>
<th class="pb-2 text-right font-medium">Value</th>
<th class="pb-2 text-right font-medium">P&L</th>
<th class="pb-2 text-right font-medium">P&L %</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for stock in summary.holdings %}
<tr class="hover:bg-gray-50">
<td class="py-2">
<span class="font-semibold text-gray-900">{{ stock.stock_code }}</span>
</td>
<td class="py-2 text-right text-gray-700">{{ stock.quantity|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.avg_cost|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">
{% if stock.current_price > 0 %}
$ {{ stock.current_price|floatformat:2 }}
{% else %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.current_value|floatformat:2 }}</td>
<td class="py-2 text-right {% if stock.unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }}
</td>
<td class="py-2 text-right {% if stock.unrealized_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<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-gray-500">No holdings in this portfolio.</p>
<p class="text-stone-400">No holdings in this portfolio.</p>
</div>
{% endif %}
</div>
+12 -16
View File
@@ -26,28 +26,24 @@
<div class="px-6 py-4">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-gray-100">
<th class="pb-2 text-left font-medium">Date</th>
<th class="pb-2 text-left font-medium">Action</th>
<th class="pb-2 text-left font-medium">Stock</th>
<th class="pb-2 text-right font-medium">Qty</th>
<th class="pb-2 text-right font-medium">Price</th>
<th class="pb-2 text-right font-medium">Total</th>
<tr 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-gray-50">
<tbody class="divide-y divide-stone-50">
{% for tx in transactions %}
<tr class="hover:bg-gray-50">
<td class="py-2 text-gray-700">{{ tx.date|date:"Y-m-d" }}</td>
<td class="py-2">
<span class="{% if tx.action == 'BUY' %}text-green-600{% else %}text-red-600{% endif %} font-medium">
<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-2 font-semibold text-gray-900">{{ tx.stock_code }}</td>
<td class="py-2 text-right text-gray-700">{{ tx.quantity|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ tx.price_per_share|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ tx.total|floatformat:2 }}</td>
<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>
+86 -4
View File
@@ -11,11 +11,93 @@ from .serializers import (
PortfolioSerializer, PortfolioListSerializer,
StockSerializer, TransactionSerializer,
AIUpdateSerializer,
PortfolioHoldingsSerializer,
)
from .services import (
get_portfolio_holdings, ai_update_holdings, add_transaction,
)
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__)