diff --git a/core/apps.py b/core/apps.py index c6148b5..642b23f 100644 --- a/core/apps.py +++ b/core/apps.py @@ -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) diff --git a/invest/migrations/0001_initial.py b/invest/migrations/0001_initial.py index 2db363d..5a44de6 100644 --- a/invest/migrations/0001_initial.py +++ b/invest/migrations/0001_initial.py @@ -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')}, }, ), ] diff --git a/invest/migrations/0002_remove_transaction_price_per_share_and_more.py b/invest/migrations/0002_remove_transaction_price_per_share_and_more.py new file mode 100644 index 0000000..9d5b3a6 --- /dev/null +++ b/invest/migrations/0002_remove_transaction_price_per_share_and_more.py @@ -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')], + }, + ), + ] diff --git a/invest/models.py b/invest/models.py index f9a72c4..699b7c1 100644 --- a/invest/models.py +++ b/invest/models.py @@ -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}" diff --git a/invest/serializers.py b/invest/serializers.py index d7358b1..8fef76d 100644 --- a/invest/serializers.py +++ b/invest/serializers.py @@ -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) diff --git a/invest/services.py b/invest/services.py index bfdb822..cab514a 100644 --- a/invest/services.py +++ b/invest/services.py @@ -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, - ) diff --git a/invest/tasks.py b/invest/tasks.py new file mode 100644 index 0000000..89a6849 --- /dev/null +++ b/invest/tasks.py @@ -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) diff --git a/invest/template_views.py b/invest/template_views.py index e8b4670..db6be71 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -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 (Jul–Jun) + now = timezone.now() + fy_start = now.year if now.month >= 7 else now.year - 1 + fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}" - 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, diff --git a/invest/templates/invest/base.html b/invest/templates/invest/base.html index 0cbd8b1..9e1644f 100644 --- a/invest/templates/invest/base.html +++ b/invest/templates/invest/base.html @@ -11,7 +11,7 @@ {% block extra_head %}{% endblock %} -
+