mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Update logic
This commit is contained in:
@@ -105,3 +105,17 @@ class CoreConfig(AppConfig):
|
||||
import sys
|
||||
print(f'routermon: startup error (non-fatal): {exc}', file=sys.stderr, flush=True)
|
||||
logger.warning("routermon: startup error (non-fatal): %s", exc)
|
||||
|
||||
# ── invest: weekly portfolio snapshot (Saturday 08:00) ─────────────────
|
||||
try:
|
||||
from invest.tasks import snapshot_all_portfolios
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler.add_job(
|
||||
snapshot_all_portfolios,
|
||||
CronTrigger(day_of_week='sat', hour=8, minute=0),
|
||||
id='weekly_portfolio_snapshot',
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("invest: scheduled weekly portfolio snapshot (Saturday 08:00)")
|
||||
except Exception as exc:
|
||||
logger.warning("invest: snapshot scheduler setup failed: %s", exc)
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}
|
||||
· ${{ 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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__)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user