Revert "feat: add cashflow-adjusted invest dashboard"

This commit is contained in:
2026-06-14 00:02:30 +10:00
committed by GitHub
parent edf1940d36
commit ebcd197513
13 changed files with 340 additions and 1536 deletions
+2 -2
View File
@@ -57,8 +57,8 @@ ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
# Use SQLITE_DATABASE_PATH for local verification against a copied DB.
'NAME': os.environ.get('SQLITE_DATABASE_PATH', BASE_DIR / 'data/db.sqlite3'),
# 'NAME': '/app/data/db.sqlite3', # Updated path
'NAME': BASE_DIR / 'data/db.sqlite3',
}
}
+2 -19
View File
@@ -1,31 +1,14 @@
from django.urls import include, path
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
AIUpdateView,
AgentSummaryView,
BenchmarkPriceViewSet,
CashFlowViewSet,
PerformanceView,
PortfolioSnapshotViewSet,
PortfolioViewSet,
RiskView,
StockViewSet,
TransactionViewSet,
)
from .views import PortfolioViewSet, StockViewSet, TransactionViewSet, AIUpdateView
router = DefaultRouter()
router.register(r'portfolios', PortfolioViewSet, basename='invest-portfolio')
router.register(r'stocks', StockViewSet, basename='invest-stock')
router.register(r'transactions', TransactionViewSet, basename='invest-transaction')
router.register(r'cashflows', CashFlowViewSet, basename='invest-cashflow')
router.register(r'snapshots', PortfolioSnapshotViewSet, basename='invest-snapshot')
router.register(r'benchmarks', BenchmarkPriceViewSet, basename='invest-benchmark')
urlpatterns = [
path('', include(router.urls)),
path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'),
path('agent/summary/', AgentSummaryView.as_view(), name='invest-agent-summary'),
path('performance/', PerformanceView.as_view(), name='invest-performance'),
path('risk/', RiskView.as_view(), name='invest-risk'),
]
@@ -1,131 +0,0 @@
# Generated by Django 5.2.12 on 2026-06-13 13:26
# Hand-adjusted so BenchmarkPrice is safe on production DBs that already have the
# hot-patched benchmark cache table.
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', '0002_remove_transaction_price_per_share_and_more'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='BenchmarkPrice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticker', models.CharField(max_length=20)),
('date', models.DateField()),
('close', models.DecimalField(decimal_places=6, max_digits=20)),
],
options={
'ordering': ['ticker', 'date'],
'unique_together': {('ticker', 'date')},
'indexes': [models.Index(fields=['ticker', 'date'], name='invest_benc_ticker_17637a_idx')],
},
),
],
database_operations=[
migrations.RunSQL(
sql=(
'CREATE TABLE IF NOT EXISTS "invest_benchmarkprice" ('
'"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, '
'"ticker" varchar(20) NOT NULL, '
'"date" date NOT NULL, '
'"close" decimal NOT NULL)'
),
reverse_sql='DROP TABLE IF EXISTS "invest_benchmarkprice"',
),
migrations.RunSQL(
sql=(
'CREATE UNIQUE INDEX IF NOT EXISTS '
'"invest_benchmarkprice_ticker_date_uniq" '
'ON "invest_benchmarkprice" ("ticker", "date")'
),
reverse_sql='DROP INDEX IF EXISTS "invest_benchmarkprice_ticker_date_uniq"',
),
migrations.RunSQL(
sql=(
'CREATE INDEX IF NOT EXISTS "invest_benc_ticker_17637a_idx" '
'ON "invest_benchmarkprice" ("ticker", "date")'
),
reverse_sql='DROP INDEX IF EXISTS "invest_benc_ticker_17637a_idx"',
),
],
),
migrations.CreateModel(
name='CashFlow',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flow_type', models.CharField(choices=[('DEPOSIT', 'Deposit'), ('WITHDRAWAL', 'Withdrawal'), ('DIVIDEND', 'Dividend'), ('FEE', 'Fee'), ('INTEREST', 'Interest'), ('TRANSFER_IN', 'Transfer In'), ('TRANSFER_OUT', 'Transfer Out')], max_length=20)),
('amount', models.DecimalField(decimal_places=2, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])),
('currency', models.CharField(default='USD', max_length=3)),
('date', models.DateField()),
('source', models.CharField(blank=True, default='', max_length=50)),
('note', models.TextField(blank=True, default='')),
('confidence', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('1'))])),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-date', '-created_at'],
},
),
migrations.AddField(
model_name='transaction',
name='broker_trade_id',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='transaction',
name='confidence',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('1'))]),
),
migrations.AddField(
model_name='transaction',
name='currency',
field=models.CharField(default='USD', max_length=3),
),
migrations.AddField(
model_name='transaction',
name='fee',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Optional broker fee/commission in transaction currency.', max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
),
migrations.AddField(
model_name='transaction',
name='price_per_share',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Optional execution price per share.', max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
),
migrations.AddField(
model_name='transaction',
name='source',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['portfolio', 'date'], name='invest_tran_portfol_962776_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['stock_code', 'date'], name='invest_tran_stock_c_90351a_idx'),
),
migrations.AddField(
model_name='cashflow',
name='portfolio',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cashflows', to='invest.portfolio'),
),
migrations.AddIndex(
model_name='cashflow',
index=models.Index(fields=['portfolio', 'date'], name='invest_cash_portfol_74c6cf_idx'),
),
migrations.AddIndex(
model_name='cashflow',
index=models.Index(fields=['flow_type', 'date'], name='invest_cash_flow_ty_53c969_idx'),
),
]
+5 -138
View File
@@ -1,12 +1,10 @@
from decimal import Decimal
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.core.validators import MinValueValidator
from decimal import Decimal
class Portfolio(models.Model):
"""Represents an investment account/portfolio (e.g., 'MOMO', 'User IBKR')."""
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
@@ -18,8 +16,7 @@ class Portfolio(models.Model):
class Stock(models.Model):
"""Current holdings for a portfolio. Quantity only — prices are fetched 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, e.g. 'NVDA', '9988.HK'")
quantity = models.DecimalField(
@@ -38,13 +35,7 @@ class Stock(models.Model):
class Transaction(models.Model):
"""
Buy/sell event log.
Price/currency/fee are intentionally optional: broker screenshots and AI/OCR syncs often
only provide ticker + quantity. When present, these fields enable cost basis and P&L.
"""
"""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')]
@@ -57,124 +48,18 @@ class Transaction(models.Model):
decimal_places=6,
validators=[MinValueValidator(Decimal('0.000001'))],
)
price_per_share = models.DecimalField(
max_digits=20,
decimal_places=6,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0'))],
help_text='Optional execution price per share.',
)
currency = models.CharField(max_length=3, default='USD')
fee = models.DecimalField(
max_digits=20,
decimal_places=6,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0'))],
help_text='Optional broker fee/commission in transaction currency.',
)
broker_trade_id = models.CharField(max_length=128, blank=True, default='')
source = models.CharField(max_length=50, blank=True, default='')
confidence = models.DecimalField(
max_digits=5,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('1'))],
)
date = models.DateField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['portfolio', 'date']),
models.Index(fields=['stock_code', 'date']),
]
def __str__(self):
return f"{self.action} {self.quantity} {self.stock_code} on {self.date}"
class CashFlow(models.Model):
"""External/internal cash ledger used for cash-flow-adjusted performance."""
FLOW_DEPOSIT = 'DEPOSIT'
FLOW_WITHDRAWAL = 'WITHDRAWAL'
FLOW_DIVIDEND = 'DIVIDEND'
FLOW_FEE = 'FEE'
FLOW_INTEREST = 'INTEREST'
FLOW_TRANSFER_IN = 'TRANSFER_IN'
FLOW_TRANSFER_OUT = 'TRANSFER_OUT'
FLOW_CHOICES = [
(FLOW_DEPOSIT, 'Deposit'),
(FLOW_WITHDRAWAL, 'Withdrawal'),
(FLOW_DIVIDEND, 'Dividend'),
(FLOW_FEE, 'Fee'),
(FLOW_INTEREST, 'Interest'),
(FLOW_TRANSFER_IN, 'Transfer In'),
(FLOW_TRANSFER_OUT, 'Transfer Out'),
]
EXTERNAL_POSITIVE = {FLOW_DEPOSIT, FLOW_TRANSFER_IN}
EXTERNAL_NEGATIVE = {FLOW_WITHDRAWAL, FLOW_TRANSFER_OUT}
VALUE_POSITIVE = {FLOW_DEPOSIT, FLOW_TRANSFER_IN, FLOW_DIVIDEND, FLOW_INTEREST}
VALUE_NEGATIVE = {FLOW_WITHDRAWAL, FLOW_TRANSFER_OUT, FLOW_FEE}
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='cashflows')
flow_type = models.CharField(max_length=20, choices=FLOW_CHOICES)
amount = models.DecimalField(
max_digits=20,
decimal_places=2,
validators=[MinValueValidator(Decimal('0.01'))],
)
currency = models.CharField(max_length=3, default='USD')
date = models.DateField()
source = models.CharField(max_length=50, blank=True, default='')
note = models.TextField(blank=True, default='')
confidence = models.DecimalField(
max_digits=5,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('1'))],
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['portfolio', 'date']),
models.Index(fields=['flow_type', 'date']),
]
@property
def signed_amount(self) -> Decimal:
if self.flow_type in self.VALUE_NEGATIVE:
return -self.amount
return self.amount
@property
def external_signed_amount(self) -> Decimal:
if self.flow_type in self.EXTERNAL_POSITIVE:
return self.amount
if self.flow_type in self.EXTERNAL_NEGATIVE:
return -self.amount
return Decimal('0')
@property
def is_external(self) -> bool:
return self.flow_type in self.EXTERNAL_POSITIVE.union(self.EXTERNAL_NEGATIVE)
def __str__(self):
return f"{self.flow_type} {self.amount} {self.currency} ({self.portfolio.name}) on {self.date}"
class PortfolioSnapshot(models.Model):
"""Periodic total-value snapshot per portfolio."""
"""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)
@@ -187,21 +72,3 @@ class PortfolioSnapshot(models.Model):
def __str__(self):
return f"{self.portfolio.name} @ {self.captured_at:%Y-%m-%d %H:%M}: ${self.total_value}"
class BenchmarkPrice(models.Model):
"""Daily close for benchmark tickers (QQQ, SPY, etc.) cached from market data."""
ticker = models.CharField(max_length=20)
date = models.DateField()
close = models.DecimalField(max_digits=20, decimal_places=6)
class Meta:
unique_together = [('ticker', 'date')]
indexes = [
models.Index(fields=['ticker', 'date']),
]
ordering = ['ticker', 'date']
def __str__(self):
return f"{self.ticker} {self.date}: ${self.close}"
+21 -78
View File
@@ -1,6 +1,5 @@
from rest_framework import serializers
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
from .models import Portfolio, Stock, Transaction
class StockSerializer(serializers.ModelSerializer):
@@ -16,79 +15,10 @@ class TransactionSerializer(serializers.ModelSerializer):
class Meta:
model = Transaction
fields = [
'id',
'portfolio',
'action',
'action_display',
'stock_code',
'quantity',
'price_per_share',
'currency',
'fee',
'broker_trade_id',
'source',
'confidence',
'date',
'created_at',
'id', 'portfolio', 'action', 'action_display',
'stock_code', 'quantity', 'date', 'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'price_per_share': {'required': False, 'allow_null': True},
'fee': {'required': False, 'allow_null': True},
'currency': {'required': False},
'broker_trade_id': {'required': False, 'allow_blank': True},
'source': {'required': False, 'allow_blank': True},
'confidence': {'required': False, 'allow_null': True},
}
class CashFlowSerializer(serializers.ModelSerializer):
flow_type_display = serializers.CharField(source='get_flow_type_display', read_only=True)
signed_amount = serializers.DecimalField(max_digits=20, decimal_places=2, read_only=True)
external_signed_amount = serializers.DecimalField(max_digits=20, decimal_places=2, read_only=True)
is_external = serializers.BooleanField(read_only=True)
class Meta:
model = CashFlow
fields = [
'id',
'portfolio',
'flow_type',
'flow_type_display',
'amount',
'signed_amount',
'external_signed_amount',
'is_external',
'currency',
'date',
'source',
'note',
'confidence',
'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'currency': {'required': False},
'source': {'required': False, 'allow_blank': True},
'note': {'required': False, 'allow_blank': True},
'confidence': {'required': False, 'allow_null': True},
}
class PortfolioSnapshotSerializer(serializers.ModelSerializer):
portfolio_name = serializers.CharField(source='portfolio.name', read_only=True)
class Meta:
model = PortfolioSnapshot
fields = ['id', 'portfolio', 'portfolio_name', 'captured_at', 'total_value']
read_only_fields = ['id']
class BenchmarkPriceSerializer(serializers.ModelSerializer):
class Meta:
model = BenchmarkPrice
fields = ['id', 'ticker', 'date', 'close']
read_only_fields = ['id']
class PortfolioSerializer(serializers.ModelSerializer):
@@ -112,6 +42,10 @@ class PortfolioListSerializer(serializers.ModelSerializer):
return obj.stocks.count()
# ---------------------------------------------------------------------------
# AI Update
# ---------------------------------------------------------------------------
class AIHoldingInputSerializer(serializers.Serializer):
stock_code = serializers.CharField()
quantity = serializers.FloatField()
@@ -123,15 +57,19 @@ class AIUpdateSerializer(serializers.Serializer):
reset = serializers.BooleanField(default=False)
# --------------------------------------------------------------------------+
# Holdings (with real-time prices) |
# -------------------------------------------------------------------------+
class HoldingSerializer(serializers.Serializer):
stock_code = serializers.CharField()
quantity = serializers.FloatField()
avg_cost = serializers.FloatField()
current_price = serializers.FloatField()
current_value = serializers.FloatField()
ref_price = serializers.FloatField(required=False, allow_null=True)
price_change = serializers.FloatField(required=False, allow_null=True)
price_change_pct = serializers.FloatField(required=False, allow_null=True)
value_change = serializers.FloatField(required=False, allow_null=True)
unrealized_pnl = serializers.FloatField()
unrealized_pnl_pct = serializers.FloatField()
class PortfolioHoldingsSerializer(serializers.Serializer):
@@ -139,9 +77,14 @@ class PortfolioHoldingsSerializer(serializers.Serializer):
portfolio_name = serializers.CharField()
holdings = HoldingSerializer(many=True)
total_value = serializers.FloatField()
total_cost = serializers.FloatField()
total_pnl = serializers.FloatField()
total_pnl_pct = serializers.FloatField()
class AIUpdateResultSerializer(serializers.Serializer):
stock_code = serializers.CharField()
quantity = serializers.FloatField()
created = serializers.BooleanField()
avg_cost = serializers.FloatField()
stock_created = serializers.BooleanField()
tx_status = serializers.CharField()
+171 -522
View File
@@ -1,71 +1,31 @@
"""
Service layer for the invest app.
Design goals:
- Keep ticker/quantity sync simple for AI/OCR workflows.
- Treat transaction price/currency/fee as optional.
- Separate account-value growth from cash-flow-adjusted investment return.
Prices fetched from Yahoo Finance on demand via yfinance.
No cost basis or P&L tracking.
"""
import json
import logging
from collections import defaultdict
from datetime import date as date_cls
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Iterable, Optional
from datetime import datetime, timedelta
from typing import Optional
from django.db.models import Sum
from django.utils import timezone
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock
from .models import Portfolio, Stock, PortfolioSnapshot
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Price cache
# In-process price cache (5 min TTL)
# ---------------------------------------------------------------------------
_price_cache: dict[str, tuple[float, datetime]] = {}
_historical_price_cache: dict[str, tuple[Optional[float], datetime]] = {}
_chart_cache: dict = {}
_PRICE_CACHE_TTL_SECONDS = 300
_HISTORICAL_CACHE_TTL_SECONDS = 3600
_CHART_CACHE_TTL = 900
SEMI_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM'}
AI_CLOUD_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM', 'NET', 'DDOG', 'GOOG', 'GOOGL', 'MSFT', 'AMZN'}
def _to_float(value) -> Optional[float]:
if value is None:
return None
return float(value)
def _as_date(value) -> Optional[date_cls]:
if value is None:
return None
if isinstance(value, datetime):
return timezone.localtime(value).date() if timezone.is_aware(value) else value.date()
if hasattr(value, 'date') and not isinstance(value, date_cls):
return value.date()
if isinstance(value, date_cls):
return value
if isinstance(value, str):
return date_cls.fromisoformat(value)
return value
# ---------------------------------------------------------------------------
# Market data
# ---------------------------------------------------------------------------
def _get_yfinance_price(stock_code: str) -> Optional[float]:
try:
import yfinance as yf
ticker = yf.Ticker(stock_code)
hist = ticker.history(period="1d")
if hist.empty:
@@ -78,7 +38,6 @@ def _get_yfinance_price(stock_code: str) -> Optional[float]:
def get_current_price(stock_code: str) -> Optional[float]:
now = datetime.now()
stock_code = stock_code.upper()
cached = _price_cache.get(stock_code)
if cached:
price, cached_at = cached
@@ -94,117 +53,23 @@ def get_current_price(stock_code: str) -> Optional[float]:
return None
def _get_historical_price(stock_code: str, ref_date) -> Optional[float]:
"""Return the close on or before ref_date, using BenchmarkPrice then yfinance fallback."""
ref_date = _as_date(ref_date)
if not ref_date:
return None
stock_code = stock_code.upper()
cache_key = f"{stock_code}:{ref_date.isoformat()}"
now = datetime.now()
# Prefer explicit DB fixtures/cache rows over in-process cache. Tests and manual backfills
# may create BenchmarkPrice rows after a previous best-effort yfinance lookup.
db_price = (
BenchmarkPrice.objects.filter(ticker=stock_code, date__lte=ref_date)
.order_by('-date')
.values_list('close', flat=True)
.first()
)
if db_price is not None:
price = float(db_price)
_historical_price_cache[cache_key] = (price, now)
return price
cached = _historical_price_cache.get(cache_key)
if cached and cached[0] is not None and (now - cached[1]).total_seconds() < _HISTORICAL_CACHE_TTL_SECONDS:
return cached[0]
price = None
try:
import yfinance as yf
start = ref_date - timedelta(days=7)
end = ref_date + timedelta(days=1)
hist = yf.Ticker(stock_code).history(start=start.isoformat(), end=end.isoformat())
if not hist.empty:
price = float(hist["Close"].iloc[-1])
BenchmarkPrice.objects.update_or_create(
ticker=stock_code,
date=hist.index[-1].date() if hasattr(hist.index[-1], 'date') else ref_date,
defaults={'close': Decimal(str(round(price, 6)))},
)
except Exception as exc:
logger.warning("historical price failed for %s @ %s: %s", stock_code, ref_date, exc)
_historical_price_cache[cache_key] = (price, now)
return price
def refresh_benchmark_prices(tickers: Iterable[str] = ('SPY', 'QQQ'), days: int = 540) -> int:
"""Best-effort benchmark cache refresh. Returns number of rows upserted."""
try:
import yfinance as yf
except Exception as exc:
logger.warning("yfinance unavailable for benchmark refresh: %s", exc)
return 0
end = timezone.now().date() + timedelta(days=1)
start = end - timedelta(days=days)
count = 0
for ticker in tickers:
try:
hist = yf.Ticker(ticker).history(start=start.isoformat(), end=end.isoformat())
rows = []
for d, v in hist['Close'].items():
row_date = d.date() if hasattr(d, 'date') else d
rows.append(BenchmarkPrice(ticker=ticker.upper(), date=row_date, close=Decimal(str(round(float(v), 6)))))
if rows:
BenchmarkPrice.objects.bulk_create(
rows,
update_conflicts=True,
unique_fields=['ticker', 'date'],
update_fields=['close'],
)
count += len(rows)
except Exception as exc:
logger.warning("benchmark refresh failed for %s: %s", ticker, exc)
return count
# ---------------------------------------------------------------------------
# Portfolio values
# Portfolio value (live prices, no cost tracking)
# ---------------------------------------------------------------------------
def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
"""Return live holdings with current prices and optional change vs reference_date."""
def get_portfolio_value(portfolio: Portfolio) -> dict:
"""Return live holdings with current prices and total value."""
holdings = []
total_value = Decimal('0')
for stock in portfolio.stocks.filter(quantity__gt=0):
ticker = stock.stock_code.upper()
price = get_current_price(ticker) or 0.0
price = get_current_price(stock.stock_code) or 0.0
value = Decimal(str(price)) * stock.quantity
ref_price = _get_historical_price(ticker, reference_date) if reference_date else None
price_change = None
price_change_pct = None
value_change = None
if price and ref_price and ref_price > 0:
price_change = round(price - ref_price, 4)
price_change_pct = round((price_change / ref_price) * 100, 2)
value_change = round(price_change * float(stock.quantity), 2)
holdings.append({
'stock_code': ticker,
'stock_code': stock.stock_code,
'quantity': float(stock.quantity),
'current_price': price,
'current_value': float(value),
'ref_price': ref_price,
'price_change': price_change,
'price_change_pct': price_change_pct,
'value_change': value_change,
})
total_value += value
@@ -216,117 +81,71 @@ def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
}
def get_all_holdings(reference_date=None) -> list[dict]:
palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
]
result = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
data = get_portfolio_value(portfolio, reference_date=reference_date)
result.append({
'portfolio': portfolio,
'colors': palette[idx % len(palette)],
'holdings': data['holdings'],
'total_value': data['total_value'],
})
return result
def _snapshot_asof(portfolio: Portfolio, target_date) -> Optional[float]:
target_date = _as_date(target_date)
if not target_date:
return None
snap = (
PortfolioSnapshot.objects.filter(portfolio=portfolio, captured_at__date__lte=target_date)
.order_by('-captured_at')
.first()
)
return float(snap.total_value) if snap else None
def get_total_value_asof(target_date=None, live_if_today: bool = True) -> Optional[float]:
target_date = _as_date(target_date)
today = timezone.now().date()
portfolios = list(Portfolio.objects.prefetch_related('stocks').all())
if target_date is None or (live_if_today and target_date == today):
total = sum(get_portfolio_value(p)['total_value'] for p in portfolios)
return float(total)
values = [_snapshot_asof(p, target_date) for p in portfolios]
values = [v for v in values if v is not None]
if not values:
return None
return float(sum(values))
def _distinct_snapshot_dates() -> list[date_cls]:
days = []
for dt in PortfolioSnapshot.objects.values_list('captured_at', flat=True).order_by('captured_at'):
day = _as_date(dt)
if day and day not in days:
days.append(day)
return days
# ---------------------------------------------------------------------------
# Weekly overview
# 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 latest snapshot and the prior snapshot at least 5 days earlier.
Uses as-of per-portfolio lookups to avoid duplicate/mixed-market snapshot dates double counting.
Compute overview from the two most recent Saturday snapshots.
Returns totals, week-over-week change, and per-portfolio rows.
"""
latest_ts = PortfolioSnapshot.objects.order_by('-captured_at').values_list('captured_at', flat=True).first()
today = timezone.now().date()
# 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
if latest_ts:
this_week_date = _as_date(latest_ts)
snapshots_are_stale = this_week_date < today
else:
this_week_date = today
snapshots_are_stale = True
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
cutoff = this_week_date - timedelta(days=5)
prev_ts = (
PortfolioSnapshot.objects.filter(captured_at__date__lte=cutoff)
.order_by('-captured_at')
.values_list('captured_at', flat=True)
.first()
)
last_week_date = _as_date(prev_ts) if prev_ts else None
this_week_total = get_total_value_asof(this_week_date if not snapshots_are_stale else today)
last_week_total = get_total_value_asof(last_week_date, live_if_today=False) if last_week_date 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 and last_week_total > 0:
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)
palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
# Per-portfolio breakdown
_palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
]
portfolio_rows = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
this_val = get_portfolio_value(portfolio)['total_value'] if snapshots_are_stale else _snapshot_asof(portfolio, this_week_date)
last_val = _snapshot_asof(portfolio, last_week_date) if last_week_date else None
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 and last_val > 0:
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,
@@ -334,7 +153,7 @@ def get_weekly_overview() -> dict:
'change': change,
'change_pct': change_pct,
'position_count': portfolio.stocks.filter(quantity__gt=0).count(),
'colors': palette[idx % len(palette)],
'colors': _palette[idx % len(_palette)],
})
return {
@@ -350,236 +169,51 @@ def get_weekly_overview() -> dict:
# ---------------------------------------------------------------------------
# Cash-flow adjusted performance
# Holdings sync (AI / manual)
# ---------------------------------------------------------------------------
def get_all_holdings() -> list[dict]:
"""
Return live holdings for every portfolio, grouped for dashboard display.
Each entry: portfolio, portfolio_color_class, holdings (list), total_value
"""
# Assign a distinct Tailwind color set per portfolio (cycled if more than defined)
palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
]
def _external_cashflows(start=None, end=None, include_start: bool = False):
qs = CashFlow.objects.all()
if start:
start_date = _as_date(start)
qs = qs.filter(date__gte=start_date) if include_start else qs.filter(date__gt=start_date)
if end:
qs = qs.filter(date__lte=_as_date(end))
return qs.order_by('date', 'created_at')
def _sum_external_cashflows(start=None, end=None, include_start: bool = False) -> Decimal:
total = Decimal('0')
for flow in _external_cashflows(start=start, end=end, include_start=include_start):
total += flow.external_signed_amount
return total
def get_net_external_cash_flow(start=None, end=None) -> float:
"""All external deposits/transfers in minus withdrawals/transfers out."""
return round(float(_sum_external_cashflows(start=start, end=end, include_start=True)), 2)
def _first_performance_date() -> Optional[date_cls]:
snapshot_date = PortfolioSnapshot.objects.order_by('captured_at').values_list('captured_at', flat=True).first()
flow_date = CashFlow.objects.order_by('date').values_list('date', flat=True).first()
candidates = [_as_date(v) for v in (snapshot_date, flow_date) if v]
return min(candidates) if candidates else None
def _xirr(cashflows: list[tuple[date_cls, Decimal]]) -> Optional[float]:
if not cashflows:
return None
if not any(amount < 0 for _, amount in cashflows) or not any(amount > 0 for _, amount in cashflows):
return None
start = cashflows[0][0]
def npv(rate: float) -> float:
total = 0.0
for flow_date, amount in cashflows:
years = (flow_date - start).days / 365.0
total += float(amount) / ((1 + rate) ** years)
return total
low, high = -0.9999, 10.0
try:
for _ in range(100):
mid = (low + high) / 2
val = npv(mid)
if abs(val) < 1e-7:
return round(mid, 6)
if val > 0:
low = mid
else:
high = mid
return round((low + high) / 2, 6)
except Exception:
return None
def _benchmark_same_cashflow(ticker: str, start: date_cls, end: date_cls, start_value: float, flows) -> Optional[dict]:
ticker = ticker.upper()
start_price = _get_historical_price(ticker, start)
end_price = _get_historical_price(ticker, end)
if not start_price or not end_price:
return None
units = Decimal(str(start_value)) / Decimal(str(start_price)) if start_value else Decimal('0')
net_external = Decimal('0')
for flow in flows:
price = _get_historical_price(ticker, flow.date)
if not price:
continue
amount = flow.external_signed_amount
net_external += amount
units += amount / Decimal(str(price))
end_value = units * Decimal(str(end_price))
cash_adjusted_gain = end_value - Decimal(str(start_value)) - net_external
capital_base = Decimal(str(start_value)) + sum(
f.external_signed_amount for f in flows if f.external_signed_amount > 0
)
return {
'ticker': ticker,
'start_price': round(start_price, 4),
'end_price': round(end_price, 4),
'end_value': round(float(end_value), 2),
'cash_adjusted_gain': round(float(cash_adjusted_gain), 2),
'simple_return': round(float(cash_adjusted_gain / capital_base), 6) if capital_base > 0 else None,
}
def get_cashflow_adjusted_performance(start=None, end=None, benchmark_tickers: Iterable[str] = ('QQQ', 'SPY')) -> dict:
end_date = _as_date(end) or timezone.now().date()
explicit_start = start is not None
start_date = _as_date(start) or _first_performance_date() or end_date
start_value = get_total_value_asof(start_date, live_if_today=False)
if start_value is None:
start_value = 0.0
end_value = get_total_value_asof(end_date)
if end_value is None:
end_value = 0.0
include_start_flows = not explicit_start and start_value == 0
flows = list(_external_cashflows(start=start_date, end=end_date, include_start=include_start_flows))
net_external = sum((flow.external_signed_amount for flow in flows), Decimal('0'))
positive_external = sum((flow.external_signed_amount for flow in flows if flow.external_signed_amount > 0), Decimal('0'))
cash_adjusted_gain = Decimal(str(end_value)) - Decimal(str(start_value)) - net_external
capital_base = Decimal(str(start_value)) + positive_external
simple_return = cash_adjusted_gain / capital_base if capital_base > 0 else None
xirr_flows = [(start_date, -Decimal(str(start_value)))] if start_value else []
for flow in flows:
xirr_flows.append((flow.date, -flow.external_signed_amount))
xirr_flows.append((end_date, Decimal(str(end_value))))
benchmarks = {}
for ticker in benchmark_tickers:
bench = _benchmark_same_cashflow(ticker, start_date, end_date, start_value, flows)
if bench:
benchmarks[ticker.upper()] = bench
return {
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'start_value': round(start_value, 2),
'end_value': round(end_value, 2),
'net_external_cash_flow': round(float(net_external), 2),
'positive_external_cash_flow': round(float(positive_external), 2),
'cash_adjusted_gain': round(float(cash_adjusted_gain), 2),
'simple_return': round(float(simple_return), 6) if simple_return is not None else None,
'money_weighted_return': _xirr(xirr_flows),
'cashflows': [
{
'id': flow.id,
'portfolio_id': flow.portfolio_id,
'flow_type': flow.flow_type,
'date': flow.date.isoformat(),
'amount': float(flow.amount),
'external_signed_amount': float(flow.external_signed_amount),
'currency': flow.currency,
}
for flow in flows
],
'benchmarks': benchmarks,
}
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
# ---------------------------------------------------------------------------
# Risk and agent summary
# Performance chart data (cumulative % from first snapshot + benchmarks)
# ---------------------------------------------------------------------------
def get_risk_summary() -> dict:
holdings = []
for group in get_all_holdings():
for holding in group['holdings']:
holdings.append({
'portfolio_id': group['portfolio'].id,
'portfolio_name': group['portfolio'].name,
**holding,
})
total_value = sum(h['current_value'] for h in holdings)
holdings.sort(key=lambda h: h['current_value'], reverse=True)
for holding in holdings:
holding['weight'] = round(holding['current_value'] / total_value, 6) if total_value else 0
top_1 = holdings[0]['weight'] if holdings else 0
top_3 = sum(h['weight'] for h in holdings[:3])
top_5 = sum(h['weight'] for h in holdings[:5])
by_ticker = defaultdict(float)
for holding in holdings:
by_ticker[holding['stock_code']] += holding['current_value']
ticker_weights = {
ticker: value / total_value for ticker, value in by_ticker.items()
} if total_value else {}
semi_weight = sum(weight for ticker, weight in ticker_weights.items() if ticker in SEMI_TICKERS)
ai_cloud_weight = sum(weight for ticker, weight in ticker_weights.items() if ticker in AI_CLOUD_TICKERS)
concentration_level = 'LOW'
if top_1 >= 0.25 or top_5 >= 0.70:
concentration_level = 'HIGH'
elif top_3 >= 0.50 or top_5 >= 0.55:
concentration_level = 'MEDIUM'
return {
'total_value': round(total_value, 2),
'position_count': len(holdings),
'top_1_weight': round(top_1, 6),
'top_3_weight': round(top_3, 6),
'top_5_weight': round(top_5, 6),
'concentration_level': concentration_level,
'max_position': holdings[0] if holdings else None,
'top_positions': holdings[:10],
'theme_exposure': {
'semiconductors': round(semi_weight, 6),
'ai_cloud': round(ai_cloud_weight, 6),
},
}
def get_agent_summary() -> dict:
total_value = get_total_value_asof()
net_external_all_time = _sum_external_cashflows()
performance = get_cashflow_adjusted_performance()
risk = get_risk_summary()
return {
'as_of': timezone.now().isoformat(),
'portfolio_count': Portfolio.objects.count(),
'total_value': round(total_value or 0, 2),
'net_external_cash_flow': round(float(net_external_all_time), 2),
'performance': performance,
'risk': risk,
}
# ---------------------------------------------------------------------------
# Performance chart data (snapshot value % vs benchmarks)
# ---------------------------------------------------------------------------
# Simple in-process cache — benchmarks don't need to refresh every page load
_chart_cache: dict = {}
_CHART_CACHE_TTL = 900 # 15 minutes
def get_performance_chart_data() -> Optional[str]:
"""
Build Chart.js-ready JSON with cumulative % return from the earliest snapshot.
Base week = 0%. Each portfolio gets a series; S&P 500 (SPY) and QQQ added as benchmarks.
Returns a JSON string (safe to pass directly to the template) or None if no snapshots.
"""
now = datetime.now()
cached = _chart_cache.get('performance')
if cached:
@@ -593,50 +227,43 @@ def get_performance_chart_data() -> Optional[str]:
def _build_performance_chart_data() -> Optional[str]:
all_snaps = list(PortfolioSnapshot.objects.select_related('portfolio').order_by('captured_at'))
if not all_snaps:
# Collect all distinct snapshot dates in ascending order
all_dates: list = []
for dt in (PortfolioSnapshot.objects
.values_list('captured_at', flat=True)
.order_by('captured_at')):
day = dt.date() if hasattr(dt, 'date') else dt
if day not in all_dates:
all_dates.append(day)
if not all_dates:
return None
portfolio_weekly: dict[int, dict[tuple[int, int], tuple[date_cls, float]]] = {}
for snap in all_snaps:
day = _as_date(snap.captured_at)
key = day.isocalendar()[:2]
portfolio_weekly.setdefault(snap.portfolio_id, {})
existing = portfolio_weekly[snap.portfolio_id].get(key)
if existing is None or day > existing[0]:
portfolio_weekly[snap.portfolio_id][key] = (day, float(snap.total_value))
base_date = all_dates[0]
all_week_keys = sorted({wk for weekly in portfolio_weekly.values() for wk in weekly})
if not all_week_keys:
return None
week_label_date = {}
for weekly in portfolio_weekly.values():
for week, (day, _) in weekly.items():
if week not in week_label_date or day > week_label_date[week]:
week_label_date[week] = day
earliest_date = week_label_date[all_week_keys[0]]
latest_date = week_label_date[all_week_keys[-1]]
refresh_needed = not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=earliest_date).exists()
if refresh_needed:
refresh_benchmark_prices()
colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
# Per-portfolio cumulative % series
portfolio_colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
datasets = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
weekly = portfolio_weekly.get(portfolio.id, {})
if not weekly:
continue
first_week = min(weekly.keys())
base_val = weekly[first_week][1]
if not base_val:
snaps = {
(snap.captured_at.date() if hasattr(snap.captured_at, 'date') else snap.captured_at): float(snap.total_value)
for snap in PortfolioSnapshot.objects.filter(portfolio=portfolio).order_by('captured_at')
}
base_val = snaps.get(base_date)
if not base_val or base_val == 0:
continue
data_pts = [
round((snaps[d] - base_val) / base_val * 100, 2) if d in snaps else None
for d in all_dates
]
color = portfolio_colors[idx % len(portfolio_colors)]
datasets.append({
'label': portfolio.name,
'data': [round((weekly[w][1] - base_val) / base_val * 100, 2) if w in weekly else None for w in all_week_keys],
'borderColor': colors[idx % len(colors)],
'backgroundColor': colors[idx % len(colors)],
'data': data_pts,
'borderColor': color,
'backgroundColor': color,
'borderWidth': 2,
'pointRadius': 5,
'pointHoverRadius': 7,
@@ -645,42 +272,64 @@ def _build_performance_chart_data() -> Optional[str]:
'fill': False,
})
def benchmark_series(ticker: str, label: str, color: str) -> Optional[dict]:
base_price = _get_historical_price(ticker, earliest_date)
if not base_price:
# Benchmark series — start 7 days before base to capture the last trading day prior
start_str = (base_date - timedelta(days=7)).isoformat()
end_str = (all_dates[-1] + timedelta(days=5)).isoformat()
def _benchmark(ticker: str, label: str, color: str) -> Optional[dict]:
try:
import yfinance as yf
hist = yf.Ticker(ticker).history(start=start_str, end=end_str)
if hist.empty:
return None
# Build date → close mapping
closes = {
(d.date() if hasattr(d, 'date') else d): float(v)
for d, v in hist['Close'].items()
}
sorted_trading_days = sorted(closes.keys())
def closest_close(target):
candidates = [td for td in sorted_trading_days if td <= target]
return closes[candidates[-1]] if candidates else None
base_price = closest_close(base_date)
if not base_price:
return None
data_pts = [
round((closest_close(d) - base_price) / base_price * 100, 2)
if closest_close(d) is not None else None
for d in all_dates
]
return {
'label': label,
'data': data_pts,
'borderColor': color,
'backgroundColor': color,
'borderWidth': 1.5,
'pointRadius': 3,
'pointHoverRadius': 5,
'tension': 0.3,
'borderDash': [5, 5],
'fill': False,
}
except Exception as exc:
logger.warning("benchmark %s failed: %s", ticker, exc)
return None
data = []
for week in all_week_keys:
price = _get_historical_price(ticker, week_label_date[week])
data.append(round((price - base_price) / base_price * 100, 2) if price else None)
return {
'label': label,
'data': data,
'borderColor': color,
'backgroundColor': color,
'borderWidth': 1.5,
'pointRadius': 3,
'pointHoverRadius': 5,
'tension': 0.3,
'borderDash': [5, 5],
'fill': False,
}
for item in (benchmark_series('SPY', 'S&P 500', '#D97706'), benchmark_series('QQQ', 'QQQ', '#16A34A')):
if item:
datasets.append(item)
spy = _benchmark('SPY', 'S&P 500', '#D97706')
qqq = _benchmark('QQQ', 'QQQ', '#16A34A')
if spy:
datasets.append(spy)
if qqq:
datasets.append(qqq)
labels = [week_label_date[w].strftime('%b %-d') for w in all_week_keys]
labels = [d.strftime('%b %-d') for d in all_dates]
return json.dumps({'labels': labels, 'datasets': datasets})
# ---------------------------------------------------------------------------
# Holdings sync (AI / manual)
# ---------------------------------------------------------------------------
def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict:
"""Update Stock records. No cost/price tracking required."""
"""Update Stock records. No cost/price tracking."""
from django.db import transaction as db_transaction
results = []
@@ -689,7 +338,7 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
portfolio.stocks.all().delete()
for item in holdings:
stock_code = item['stock_code'].upper()
stock_code = item['stock_code']
quantity = Decimal(str(item['quantity']))
stock, created = Stock.objects.update_or_create(
@@ -698,7 +347,7 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
defaults={'quantity': quantity},
)
results.append({
'stock_code': stock.stock_code,
'stock_code': stock_code,
'quantity': float(quantity),
'created': created,
})
+3 -4
View File
@@ -3,6 +3,7 @@ Background tasks for the invest app.
"""
import logging
from decimal import Decimal
from datetime import datetime
logger = logging.getLogger(__name__)
@@ -13,9 +14,8 @@ def snapshot_all_portfolios():
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, refresh_benchmark_prices
from .services import get_portfolio_value
now = timezone.now()
today = now.date()
@@ -27,7 +27,7 @@ def snapshot_all_portfolios():
data = get_portfolio_value(portfolio)
total_value = Decimal(str(data['total_value']))
# One snapshot per portfolio per day — overwrite if run twice.
# One snapshot per portfolio per day — overwrite if run twice
PortfolioSnapshot.objects.filter(
portfolio=portfolio,
captured_at__date=today,
@@ -43,5 +43,4 @@ def snapshot_all_portfolios():
except Exception as exc:
logger.error("invest: snapshot failed for %s: %s", portfolio.name, exc, exc_info=True)
refresh_benchmark_prices()
logger.info("invest: snapshot complete — %d portfolios", count)
+8 -23
View File
@@ -1,33 +1,26 @@
"""Template views for the invest app."""
import logging
from django.shortcuts import get_object_or_404, render
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from .models import Portfolio, Transaction
from .services import (
get_all_holdings,
get_cashflow_adjusted_performance,
get_net_external_cash_flow,
get_performance_chart_data,
get_portfolio_value,
get_risk_summary,
get_weekly_overview,
)
from .models import Portfolio
from .services import get_portfolio_value, get_weekly_overview, get_all_holdings, get_performance_chart_data
logger = logging.getLogger(__name__)
def dashboard(request):
"""Landing page: agent-first metrics + human-readable holdings/risk dashboard."""
"""Landing page: weekly snapshot overview + per-portfolio table."""
overview = get_weekly_overview()
# Determine current Australian financial year (JulJun)
now = timezone.now()
fy_start = now.year if now.month >= 7 else now.year - 1
fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}"
reference_date = overview.get('last_week_date')
all_holdings = get_all_holdings(reference_date=reference_date)
all_holdings = get_all_holdings()
# Merge snapshot data (value, change, change_pct) into each holdings group
rows_by_id = {row['portfolio'].id: row for row in overview.get('portfolio_rows', [])}
for group in all_holdings:
row = rows_by_id.get(group['portfolio'].id, {})
@@ -36,24 +29,16 @@ def dashboard(request):
group['change_pct'] = row.get('change_pct')
group['position_count'] = row.get('position_count', len(group['holdings']))
performance = get_cashflow_adjusted_performance()
risk = get_risk_summary()
recent_transactions = Transaction.objects.select_related('portfolio').order_by('-date', '-created_at')[:30]
return render(request, 'invest/dashboard.html', {
'overview': overview,
'fy_label': fy_label,
'all_holdings': all_holdings,
'chart_data_json': get_performance_chart_data() or 'null',
'performance': performance,
'net_contributions': get_net_external_cash_flow(),
'risk': risk,
'recent_transactions': recent_transactions,
})
def portfolio_detail(request, pk):
"""Portfolio detail: live holdings."""
"""Portfolio detail: live holdings, no cost/P&L."""
portfolio = get_object_or_404(Portfolio, pk=pk)
try:
summary = get_portfolio_value(portfolio)
+97 -140
View File
@@ -5,15 +5,17 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
{% endblock %}
{% block title %}Invest Dashboard{% endblock %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="mb-2">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} Snapshot</p>
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} SNAPSHOT</p>
</div>
<!-- ── Top metric cards ──────────────────────────────────────── -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
<!-- Total value -->
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Total Value</p>
{% if overview.this_week_total is not None %}
@@ -21,169 +23,127 @@
{% 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>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Net Contributions</p>
<p class="text-3xl font-bold text-blue-800">${{ net_contributions|floatformat:0 }}</p>
<p class="text-sm text-stone-400 mt-1">External deposits minus withdrawals</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">Investment Gain</p>
<p class="text-3xl font-bold {% if performance.cash_adjusted_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0 }}
<p class="text-sm text-stone-400 mt-1">
Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}
</p>
<p class="text-sm text-stone-400 mt-1">Cash-flow adjusted</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">Top 5 Concentration</p>
<p class="text-3xl font-bold {% if risk.concentration_level == 'HIGH' %}text-red-700{% elif risk.concentration_level == 'MEDIUM' %}text-amber-700{% else %}text-green-800{% endif %}">
{% widthratio risk.top_5_weight 1 100 %}%
</p>
<p class="text-sm text-stone-400 mt-1">Risk: {{ risk.concentration_level }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-3">
<!-- 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-2xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
<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 snapshot</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-2xl font-bold text-stone-400"></p>
<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">Money Weighted Return</p>
{% if performance.money_weighted_return is not None %}
<p class="text-2xl font-bold text-stone-900">{% widthratio performance.money_weighted_return 1 100 %}%</p>
<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-2xl font-bold text-stone-400"></p>
<p class="text-3xl font-bold text-stone-400"></p>
<p class="text-sm text-stone-400 mt-1">Need 2+ snapshots</p>
{% endif %}
<p class="text-sm text-stone-400 mt-1">IRR based on cash flows</p>
</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-2xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
<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-2xl font-bold text-stone-400"></p>
<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>
<!-- ── Same-cashflow benchmark ───────────────────────────────── -->
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Same-cashflow Benchmark</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<p class="text-sm text-stone-400">Actual end value</p>
<p class="text-xl font-bold text-stone-900">${{ performance.end_value|floatformat:0 }}</p>
</div>
{% for ticker, bench in performance.benchmarks.items %}
<div>
<p class="text-sm text-stone-400">Same cash flows into {{ ticker }}</p>
<p class="text-xl font-bold text-stone-900">${{ bench.end_value|floatformat:0 }}</p>
<p class="text-xs text-stone-400">Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}</p>
</div>
{% empty %}
<div class="text-sm text-stone-400">Benchmark prices unavailable. The API still returns portfolio metrics.</div>
{% endfor %}
</div>
</div>
<!-- ── Performance chart ─────────────────────────────────────── -->
{% if chart_data_json != 'null' %}
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">{{ fy_label }} Performance vs Benchmarks</p>
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">
{{ fy_label }} Performance vs Benchmarks
</p>
<canvas id="performanceChart" height="90"></canvas>
<p class="text-xs text-stone-400 mt-3">Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.</p>
</div>
{% endif %}
<!-- ── Risk panel ────────────────────────────────────────────── -->
<div class="bg-white rounded-lg shadow-sm p-5 mb-4">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Risk Overview</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div><p class="text-xs text-stone-400">Top 1</p><p class="font-bold">{% widthratio risk.top_1_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Top 3</p><p class="font-bold">{% widthratio risk.top_3_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Semiconductors</p><p class="font-bold">{% widthratio risk.theme_exposure.semiconductors 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">AI / Cloud</p><p class="font-bold">{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%</p></div>
</div>
<div class="overflow-x-auto">
<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="py-2 text-left">Ticker</th>
<th class="py-2 text-right">Value</th>
<th class="py-2 text-right">Weight</th>
<th class="py-2 text-left">Portfolio</th>
</tr>
</thead>
<tbody>
{% for position in risk.top_positions|slice:":5" %}
<tr class="border-b border-stone-50">
<td class="py-2 font-medium">{{ position.stock_code }}</td>
<td class="py-2 text-right">${{ position.current_value|floatformat:0 }}</td>
<td class="py-2 text-right">{% widthratio position.weight 1 100 %}%</td>
<td class="py-2 text-stone-500">{{ position.portfolio_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- ── Hint ──────────────────────────────────────────────────── -->
<p class="text-xs text-stone-400 text-center mt-2 mb-6">
Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency
</p>
<p class="text-xs text-stone-400 text-center mt-2 mb-6">Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync</p>
<!-- ── Live holdings ─────────────────────────────────────────── -->
<!-- ── Live holdings (per-portfolio cards) ───────────────────── -->
{% if all_holdings %}
<div class="space-y-4">
{% for group in all_holdings %}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- Card header -->
<div class="px-6 py-4 flex items-center justify-between border-b border-stone-100">
<div>
<p class="font-semibold text-stone-900 text-sm">{{ group.portfolio.name }}</p>
<p class="text-xs text-stone-400 mt-0.5">{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}</p>
<p class="font-semibold text-stone-900 text-sm">
{{ group.portfolio.name }}
</p>
<p class="text-xs text-stone-400 mt-0.5">
{{ group.position_count }} position{{ group.position_count|pluralize }}
{% if overview.this_week_date %}· Last updated {{ overview.this_week_date|date:"j M Y" }}{% endif %}
</p>
</div>
<div class="text-right">
<p class="font-bold text-stone-900 text-sm">{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}</p>
<p class="font-bold text-stone-900 text-sm">
{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</p>
{% if group.change is not None %}
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)</p>
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">
{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }}
({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)
</p>
{% else %}
<p class="text-xs text-stone-300 mt-0.5">No prior snapshot</p>
{% endif %}
</div>
</div>
<!-- Holdings table -->
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="px-6 py-3 text-left">Ticker</th>
<th class="px-6 py-3 text-right">Qty</th>
<th class="px-6 py-3 text-right">Price</th>
<th class="px-6 py-3 text-right">Week Change</th>
<th class="px-6 py-3 text-right">Mkt Value</th>
</tr>
</thead>
<tbody>
{% for stock in group.holdings %}
<tr class="border-b border-stone-50 hover:bg-stone-50 {{ group.colors.row }}">
<td class="px-6 py-3 font-medium text-stone-900"><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">{{ stock.stock_code }}</span></td>
<td class="px-6 py-3 font-medium text-stone-900">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">
{{ stock.stock_code }}
</span>
</td>
<td class="px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:0 }}</td>
<td class="px-6 py-3 text-right text-stone-500">{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 text-right {% if stock.value_change >= 0 %}text-green-700{% elif stock.value_change < 0 %}text-red-600{% else %}text-stone-300{% endif %}">{% if stock.value_change is not None %}{% if stock.value_change >= 0 %}+{% endif %}${{ stock.value_change|floatformat:0 }} ({% if stock.price_change_pct >= 0 %}+{% endif %}{{ stock.price_change_pct|floatformat:1 }}%){% else %}—{% endif %}</td>
<td class="px-6 py-3 text-right font-semibold text-stone-800">{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 text-right text-stone-500">
{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</td>
<td class="px-6 py-3 text-right font-semibold text-stone-800">
{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -193,41 +153,6 @@
</div>
{% endif %}
<!-- ── Transactions ──────────────────────────────────────────── -->
{% if recent_transactions %}
<div class="bg-white rounded-lg shadow-sm p-5 mt-4">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Transaction History</p>
<div class="overflow-x-auto">
<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="py-2 text-left">Date</th>
<th class="py-2 text-left">Portfolio</th>
<th class="py-2 text-left">Action</th>
<th class="py-2 text-left">Ticker</th>
<th class="py-2 text-right">Qty</th>
<th class="py-2 text-right">Price</th>
<th class="py-2 text-right">Fee</th>
</tr>
</thead>
<tbody>
{% for tx in recent_transactions %}
<tr class="border-b border-stone-50">
<td class="py-2">{{ tx.date|date:"j M Y" }}</td>
<td class="py-2">{{ tx.portfolio.name }}</td>
<td class="py-2">{{ tx.action }}</td>
<td class="py-2 font-medium">{{ tx.stock_code }}</td>
<td class="py-2 text-right">{{ tx.quantity|floatformat:0 }}</td>
<td class="py-2 text-right">{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2 }}{% else %}<span class="text-stone-300">optional</span>{% endif %}</td>
<td class="py-2 text-right">{% if tx.fee %}${{ tx.fee|floatformat:2 }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
@@ -236,8 +161,10 @@
(function () {
const raw = {{ chart_data_json|safe }};
if (!raw) return;
const ctx = document.getElementById('performanceChart');
if (!ctx) return;
new Chart(ctx, {
type: 'line',
data: raw,
@@ -245,12 +172,42 @@
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', align: 'start', labels: { usePointStyle: true, pointStyle: 'rect', pointStyleWidth: 14, padding: 20, font: { size: 12, weight: '600' } } },
tooltip: { callbacks: { label: function (ctx) { const v = ctx.parsed.y; if (v === null || v === undefined) return ctx.dataset.label + ': —'; const sign = v >= 0 ? '+' : ''; return ctx.dataset.label + ': ' + sign + v.toFixed(2) + '%'; } } },
legend: {
position: 'top',
align: 'start',
labels: {
usePointStyle: true,
pointStyle: 'rect',
pointStyleWidth: 14,
padding: 20,
font: { size: 12, weight: '600' },
},
},
tooltip: {
callbacks: {
label: function (ctx) {
const v = ctx.parsed.y;
if (v === null || v === undefined) return ctx.dataset.label + ': —';
const sign = v >= 0 ? '+' : '';
return ctx.dataset.label + ': ' + sign + v.toFixed(2) + '%';
},
},
},
},
scales: {
y: { ticks: { callback: function (v) { return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }, font: { size: 11 } }, grid: { color: '#f5f5f4' } },
x: { grid: { display: false }, ticks: { font: { size: 11 } } },
y: {
ticks: {
callback: function (v) {
return (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
},
font: { size: 11 },
},
grid: { color: '#f5f5f4' },
},
x: {
grid: { display: false },
ticks: { font: { size: 11 } },
},
},
},
});
+28 -85
View File
@@ -1,29 +1,18 @@
import logging
from django.shortcuts import get_object_or_404
from rest_framework import status, viewsets
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
from .models import Portfolio, Stock, Transaction
from .serializers import (
PortfolioSerializer, PortfolioListSerializer,
StockSerializer, TransactionSerializer,
AIUpdateSerializer,
BenchmarkPriceSerializer,
CashFlowSerializer,
PortfolioListSerializer,
PortfolioSerializer,
PortfolioSnapshotSerializer,
StockSerializer,
TransactionSerializer,
)
from .services import (
ai_update_holdings,
get_agent_summary,
get_cashflow_adjusted_performance,
get_portfolio_value,
get_risk_summary,
)
from .services import get_portfolio_value, ai_update_holdings
logger = logging.getLogger(__name__)
@@ -40,9 +29,8 @@ class PortfolioViewSet(viewsets.ModelViewSet):
def holdings(self, request, pk=None):
"""Return holdings with real-time prices."""
portfolio = self.get_object()
reference_date = request.query_params.get('reference_date')
try:
data = get_portfolio_value(portfolio, reference_date=reference_date)
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)
@@ -83,51 +71,34 @@ class TransactionViewSet(viewsets.ModelViewSet):
qs = qs.filter(stock_code=stock_code.upper())
return qs.order_by('-date', '-created_at')
def perform_create(self, serializer):
serializer.save(stock_code=serializer.validated_data['stock_code'].upper())
def create(self, request, *args, **kwargs):
"""Create a new transaction."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
portfolio = data['portfolio']
class CashFlowViewSet(viewsets.ModelViewSet):
queryset = CashFlow.objects.select_related('portfolio').all()
serializer_class = CashFlowSerializer
try:
tx = Transaction.objects.create(
portfolio=portfolio,
action=data['action'],
stock_code=data['stock_code'].upper(),
quantity=data['quantity'],
date=data['date'],
)
except Exception as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
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)
flow_type = self.request.query_params.get('flow_type')
if flow_type:
qs = qs.filter(flow_type=flow_type.upper())
return qs.order_by('-date', '-created_at')
class PortfolioSnapshotViewSet(viewsets.ReadOnlyModelViewSet):
queryset = PortfolioSnapshot.objects.select_related('portfolio').all()
serializer_class = PortfolioSnapshotSerializer
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.order_by('-captured_at')
class BenchmarkPriceViewSet(viewsets.ReadOnlyModelViewSet):
queryset = BenchmarkPrice.objects.all()
serializer_class = BenchmarkPriceSerializer
def get_queryset(self):
qs = super().get_queryset()
ticker = self.request.query_params.get('ticker')
if ticker:
qs = qs.filter(ticker=ticker.upper())
return qs.order_by('ticker', 'date')
out = TransactionSerializer(tx)
return Response(out.data, status=status.HTTP_201_CREATED)
class AIUpdateView(APIView):
"""POST /api/invest/ai-update/ — Sync portfolio holdings (quantity only required)."""
"""
POST /api/invest/ai-update/
Sync portfolio holdings (quantity only, no price).
"""
def post(self, request):
serializer = AIUpdateSerializer(data=request.data)
@@ -149,31 +120,3 @@ class AIUpdateView(APIView):
return Response(result, status=status.HTTP_200_OK)
class AgentSummaryView(APIView):
"""GET /api/invest/agent/summary/ — agent-friendly portfolio summary."""
def get(self, request):
return Response(get_agent_summary())
class PerformanceView(APIView):
"""GET /api/invest/performance/?start=YYYY-MM-DD&end=YYYY-MM-DD&benchmarks=QQQ,SPY"""
def get(self, request):
benchmarks = request.query_params.get('benchmarks', 'QQQ,SPY')
tickers = [item.strip().upper() for item in benchmarks.split(',') if item.strip()]
return Response(
get_cashflow_adjusted_performance(
start=request.query_params.get('start'),
end=request.query_params.get('end'),
benchmark_tickers=tickers,
)
)
class RiskView(APIView):
"""GET /api/invest/risk/ — concentration and theme exposure."""
def get(self, request):
return Response(get_risk_summary())
-1
View File
@@ -28,7 +28,6 @@ dependencies = [
"cryptography>=42.0.0",
"kubernetes>=29.0.0",
"maxminddb>=3.1.1",
"yfinance>=0.2.66",
]
[build-system]
-169
View File
@@ -1,169 +0,0 @@
from datetime import date
from decimal import Decimal
import pytest
from django.utils import timezone
from invest.models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
@pytest.mark.django_db
def test_transaction_price_fields_are_optional(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
response = api_client.post(
"/api/invest/transactions/",
{
"portfolio": portfolio.id,
"action": "BUY",
"stock_code": "NVDA",
"quantity": "2",
"date": "2026-06-13",
},
format="json",
)
assert response.status_code == 201
tx = Transaction.objects.get()
assert tx.price_per_share is None
assert tx.currency == "USD"
assert tx.fee is None
assert response.data["price_per_share"] is None
@pytest.mark.django_db
def test_transaction_accepts_optional_price_currency_and_fee(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
response = api_client.post(
"/api/invest/transactions/",
{
"portfolio": portfolio.id,
"action": "BUY",
"stock_code": "NVDA",
"quantity": "2",
"price_per_share": "100.25",
"currency": "USD",
"fee": "1.50",
"date": "2026-06-13",
},
format="json",
)
assert response.status_code == 201
tx = Transaction.objects.get()
assert tx.price_per_share == Decimal("100.250000")
assert tx.fee == Decimal("1.500000")
assert response.data["price_per_share"] == "100.250000"
assert response.data["fee"] == "1.500000"
@pytest.mark.django_db
def test_cashflow_api_records_external_deposits(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
response = api_client.post(
"/api/invest/cashflows/",
{
"portfolio": portfolio.id,
"flow_type": "DEPOSIT",
"amount": "2500.00",
"currency": "USD",
"date": "2026-06-13",
"source": "salary",
"note": "monthly contribution",
},
format="json",
)
assert response.status_code == 201
flow = CashFlow.objects.get()
assert flow.signed_amount == Decimal("2500.00")
assert response.data["signed_amount"] == "2500.00"
@pytest.mark.django_db
def test_agent_summary_reports_cash_adjusted_return_and_concentration(api_client, monkeypatch):
portfolio = Portfolio.objects.create(name="Agent Test")
Stock.objects.create(portfolio=portfolio, stock_code="AAA", quantity=Decimal("10"))
Stock.objects.create(portfolio=portfolio, stock_code="BBB", quantity=Decimal("5"))
CashFlow.objects.create(
portfolio=portfolio,
flow_type=CashFlow.FLOW_DEPOSIT,
amount=Decimal("1000.00"),
currency="USD",
date=date(2026, 6, 1),
)
prices = {"AAA": 100.0, "BBB": 20.0}
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: prices[ticker])
response = api_client.get("/api/invest/agent/summary/")
assert response.status_code == 200
payload = response.json()
assert payload["total_value"] == 1100.0
assert payload["performance"]["net_external_cash_flow"] == 1000.0
assert payload["performance"]["cash_adjusted_gain"] == 100.0
assert payload["risk"]["top_1_weight"] == pytest.approx(0.9091, rel=1e-3)
assert payload["risk"]["max_position"]["stock_code"] == "AAA"
assert payload["risk"]["concentration_level"] == "HIGH"
@pytest.mark.django_db
def test_performance_endpoint_excludes_deposits_from_gain(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
PortfolioSnapshot.objects.create(
portfolio=portfolio,
captured_at=timezone.make_aware(timezone.datetime(2026, 6, 1, 8, 0)),
total_value=Decimal("1000.00"),
)
CashFlow.objects.create(
portfolio=portfolio,
flow_type=CashFlow.FLOW_DEPOSIT,
amount=Decimal("500.00"),
currency="USD",
date=date(2026, 6, 8),
)
PortfolioSnapshot.objects.create(
portfolio=portfolio,
captured_at=timezone.make_aware(timezone.datetime(2026, 6, 15, 8, 0)),
total_value=Decimal("1700.00"),
)
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 6, 1), close=Decimal("100.00"))
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 6, 8), close=Decimal("110.00"))
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 6, 15), close=Decimal("120.00"))
response = api_client.get("/api/invest/performance/?start=2026-06-01&end=2026-06-15")
assert response.status_code == 200
payload = response.json()
assert payload["start_value"] == 1000.0
assert payload["end_value"] == 1700.0
assert payload["net_external_cash_flow"] == 500.0
assert payload["cash_adjusted_gain"] == 200.0
assert payload["simple_return"] == pytest.approx(0.1333, rel=1e-3)
assert payload["benchmarks"]["QQQ"]["end_value"] == pytest.approx(1745.45, rel=1e-3)
@pytest.mark.django_db
def test_dashboard_shows_agent_first_metrics(client, monkeypatch):
portfolio = Portfolio.objects.create(name="Agent Test")
Stock.objects.create(portfolio=portfolio, stock_code="AAA", quantity=Decimal("10"))
CashFlow.objects.create(
portfolio=portfolio,
flow_type=CashFlow.FLOW_DEPOSIT,
amount=Decimal("1000.00"),
currency="USD",
date=date(2026, 6, 1),
)
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 110.0)
response = client.get("/invest/")
assert response.status_code == 200
content = response.content.decode()
assert "Net Contributions" in content
assert "Investment Gain" in content
assert "Top 5 Concentration" in content
assert "Same-cashflow Benchmark" in content
Generated
+3 -224
View File
@@ -2,15 +2,9 @@ version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version < '3.13'",
]
[[package]]
@@ -303,39 +297,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
[[package]]
name = "curl-cffi"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "cffi" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" },
{ url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" },
{ url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" },
{ url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" },
{ url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" },
{ url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" },
{ url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" },
{ url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" },
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" },
{ url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" },
{ url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" },
{ url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" },
{ url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" },
{ url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" },
]
[[package]]
name = "django"
version = "5.2.12"
@@ -686,7 +647,6 @@ dependencies = [
{ name = "sqlparse" },
{ name = "whitenoise" },
{ name = "whoosh" },
{ name = "yfinance" },
]
[package.optional-dependencies]
@@ -735,7 +695,6 @@ requires-dist = [
{ name = "sqlparse", specifier = "==0.5.1" },
{ name = "whitenoise", specifier = "==5.3.0" },
{ name = "whoosh", specifier = "==2.7.4" },
{ name = "yfinance", specifier = ">=0.2.66" },
]
provides-extras = ["dev"]
@@ -754,18 +713,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/34/3d1ff0cb4843a33817d06800e9383a2b2a2df4d508e37f53a40e829905d9/markdown-3.8.1-py3-none-any.whl", hash = "sha256:46cc0c0f1e5211ab2e9d453582f0b28a1bfaf058a9f7d5c50386b99b588d8811", size = 106642, upload-time = "2025-06-18T14:50:48.52Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "maxminddb"
version = "3.1.1"
@@ -831,24 +778,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "multitasking"
version = "0.0.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/c3/ac2cc9307fb15cc28ed6d4a9266b216c83ee7fe64299f0264047982bce88/multitasking-0.0.13.tar.gz", hash = "sha256:d896b5df877c9ca5eeddbf0e5994124694d6cb535aba698fb23344c7025155a1", size = 20585, upload-time = "2026-04-23T12:14:15.049Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/1c/24dbf69b247f287401c904a396233a43c89fd4fb9b7cd2e50e430e9cd57c/multitasking-0.0.13-py3-none-any.whl", hash = "sha256:ec9243af140c67bfe52dc98d7173c294512735a88e8425c458b250db99dc2b48", size = 16380, upload-time = "2026-04-23T12:14:13.776Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
@@ -937,58 +866,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
]
[[package]]
name = "pandas"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" },
{ url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" },
{ url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" },
{ url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" },
{ url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" },
{ url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" },
{ url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" },
{ url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" },
{ url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" },
{ url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" },
{ url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" },
{ url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" },
{ url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" },
{ url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" },
{ url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" },
{ url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" },
{ url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" },
{ url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" },
{ url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" },
{ url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" },
{ url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" },
{ url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" },
{ url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" },
{ url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" },
{ url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" },
{ url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" },
{ url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
@@ -998,15 +875,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "peewee"
version = "4.0.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/87/4ba5dc4d69bb650846fcb4922fa4b0c3a41e413fd13ce70d8bee220afe45/peewee-4.0.7.tar.gz", hash = "sha256:39ba16d41519de40e83a9681475f981313686d7885425139ef8db2d2bb0dfd15", size = 744667, upload-time = "2026-06-11T13:26:01.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/10/2de61e392b878a74baf964215acf16b7c4813f03126d6f6875331b4e351e/peewee-4.0.7-py3-none-any.whl", hash = "sha256:aa6e07c95f9cb784813d14f19910f105e819ac3a2c2b8f9eca71ddb028fdcffd", size = 153433, upload-time = "2026-06-11T13:26:00.686Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
@@ -1352,15 +1220,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]]
name = "pytz"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -1478,19 +1337,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "s3transfer"
version = "0.11.2"
@@ -1599,51 +1445,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "whitenoise"
version = "5.3.0"
@@ -1661,25 +1462,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/25/2b/6beed2107b148edc1
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/24d0f1f454a2c1eb689ca28d2f178db81e5024f42d82729a4ff6771155cf/Whoosh-2.7.4-py2.py3-none-any.whl", hash = "sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852", size = 468790, upload-time = "2016-04-04T01:19:40.379Z" },
]
[[package]]
name = "yfinance"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "curl-cffi" },
{ name = "multitasking" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "peewee" },
{ name = "platformdirs" },
{ name = "protobuf" },
{ name = "pytz" },
{ name = "requests" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/d2/941eea19644200c3f82e1be35a1faa94f1149760a0b6f43e3633bfa052d5/yfinance-1.4.1.tar.gz", hash = "sha256:9acecec3036b4aa96d1e3120ff85ca4f6f81d239d968f56b6eb7877f89fea7a3", size = 153823, upload-time = "2026-05-28T20:03:05.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/ec/8f432c0370e667fb0d8a54ffb75d7737c9224d68eca0db91ee1bd84f74ee/yfinance-1.4.1-py2.py3-none-any.whl", hash = "sha256:1e1c506ca81dc15635380e7129813a5b32da80201af9bb404cac5d528ecfddc3", size = 137770, upload-time = "2026-05-28T20:03:04.584Z" },
]