From ebcd197513c4e0461c202eb9ad24e5b486a7e9b0 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 14 Jun 2026 00:02:30 +1000 Subject: [PATCH] Revert "feat: add cashflow-adjusted invest dashboard" --- core/settings.py | 4 +- invest/api_urls.py | 21 +- ...ow_transaction_broker_trade_id_and_more.py | 131 ---- invest/models.py | 143 +--- invest/serializers.py | 99 +-- invest/services.py | 693 +++++------------- invest/tasks.py | 7 +- invest/template_views.py | 31 +- invest/templates/invest/dashboard.html | 237 +++--- invest/views.py | 113 +-- pyproject.toml | 1 - tests/test_invest_api.py | 169 ----- uv.lock | 227 +----- 13 files changed, 340 insertions(+), 1536 deletions(-) delete mode 100644 invest/migrations/0003_benchmarkprice_cashflow_transaction_broker_trade_id_and_more.py delete mode 100644 tests/test_invest_api.py diff --git a/core/settings.py b/core/settings.py index 9564fd0..ffa65e3 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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', } } diff --git a/invest/api_urls.py b/invest/api_urls.py index 22fd052..2680424 100644 --- a/invest/api_urls.py +++ b/invest/api_urls.py @@ -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'), ] diff --git a/invest/migrations/0003_benchmarkprice_cashflow_transaction_broker_trade_id_and_more.py b/invest/migrations/0003_benchmarkprice_cashflow_transaction_broker_trade_id_and_more.py deleted file mode 100644 index a3e48d7..0000000 --- a/invest/migrations/0003_benchmarkprice_cashflow_transaction_broker_trade_id_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/invest/models.py b/invest/models.py index 03acd6f..699b7c1 100644 --- a/invest/models.py +++ b/invest/models.py @@ -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}" diff --git a/invest/serializers.py b/invest/serializers.py index 416ded8..b99f380 100644 --- a/invest/serializers.py +++ b/invest/serializers.py @@ -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() diff --git a/invest/services.py b/invest/services.py index 4229ce2..13e304a 100644 --- a/invest/services.py +++ b/invest/services.py @@ -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, }) diff --git a/invest/tasks.py b/invest/tasks.py index 4b65aff..89a6849 100644 --- a/invest/tasks.py +++ b/invest/tasks.py @@ -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) diff --git a/invest/template_views.py b/invest/template_views.py index 376149b..98d5b56 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -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 (Jul–Jun) now = timezone.now() fy_start = now.year if now.month >= 7 else now.year - 1 fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}" - 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) diff --git a/invest/templates/invest/dashboard.html b/invest/templates/invest/dashboard.html index b74e90e..daeacf4 100644 --- a/invest/templates/invest/dashboard.html +++ b/invest/templates/invest/dashboard.html @@ -5,15 +5,17 @@ {% endblock %} -{% block title %}Invest Dashboard{% endblock %} +{% block title %}Dashboard{% endblock %} {% block content %}
-

{{ fy_label }} Snapshot

+

{{ fy_label }} SNAPSHOT

+ +

Total Value

{% if overview.this_week_total is not None %} @@ -21,169 +23,127 @@ {% else %}

{% endif %} -

Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}

-
- -
-

Net Contributions

-

${{ net_contributions|floatformat:0 }}

-

External deposits minus withdrawals

-
- -
-

Investment Gain

-

- {% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0 }} +

+ Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}

-

Cash-flow adjusted

-
-

Top 5 Concentration

-

- {% widthratio risk.top_5_weight 1 100 %}% -

-

Risk: {{ risk.concentration_level }}

-
-
- -
+

This Week

{% if overview.week_gain is not None %} -

+

{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0 }}

-

{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last snapshot

+

+ {% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last week +

{% else %} -

+

No prior snapshot

{% endif %}
+
-

Money Weighted Return

- {% if performance.money_weighted_return is not None %} -

{% widthratio performance.money_weighted_return 1 100 %}%

+

Week Change

+ {% if overview.week_change_pct is not None %} +

+ {% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% +

+ {% if overview.last_week_date %} +

Last: {{ overview.last_week_date|date:"M j" }}

+ {% endif %} {% else %} -

+

+

Need 2+ snapshots

{% endif %} -

IRR based on cash flows

+

Last Snapshot

{% if overview.this_week_date %} -

{{ overview.this_week_date|date:"M j" }}

+

{{ overview.this_week_date|date:"M j" }}

{{ overview.this_week_date|date:"l, Y" }}

{% else %} -

+

No snapshots yet

{% endif %}
-
- -
-

Same-cashflow Benchmark

-
-
-

Actual end value

-

${{ performance.end_value|floatformat:0 }}

-
- {% for ticker, bench in performance.benchmarks.items %} -
-

Same cash flows into {{ ticker }}

-

${{ bench.end_value|floatformat:0 }}

-

Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}

-
- {% empty %} -
Benchmark prices unavailable. The API still returns portfolio metrics.
- {% endfor %} -
{% if chart_data_json != 'null' %}
-

{{ fy_label }} Performance vs Benchmarks

+

+ {{ fy_label }} Performance vs Benchmarks +

-

Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.

{% endif %} - -
-

Risk Overview

-
-

Top 1

{% widthratio risk.top_1_weight 1 100 %}%

-

Top 3

{% widthratio risk.top_3_weight 1 100 %}%

-

Semiconductors

{% widthratio risk.theme_exposure.semiconductors 1 100 %}%

-

AI / Cloud

{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%

-
-
- - - - - - - - - - - {% for position in risk.top_positions|slice:":5" %} - - - - - - - {% endfor %} - -
TickerValueWeightPortfolio
{{ position.stock_code }}${{ position.current_value|floatformat:0 }}{% widthratio position.weight 1 100 %}%{{ position.portfolio_name }}
-
-
+ +

+ Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency +

-

Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync

- - + {% if all_holdings %}
{% for group in all_holdings %}
+
-

{{ group.portfolio.name }}

-

{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}

+

+ {{ group.portfolio.name }} +

+

+ {{ group.position_count }} position{{ group.position_count|pluralize }} + {% if overview.this_week_date %}· Last updated {{ overview.this_week_date|date:"j M Y" }}{% endif %} +

-

{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}{% endif %}

+

+ {% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}{% endif %} +

{% if group.change is not None %} -

{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)

+

+ {% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} + ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%) +

{% else %}

No prior snapshot

{% endif %}
+ - {% for stock in group.holdings %} - + - - - + + {% endfor %} @@ -193,41 +153,6 @@ {% endif %} - -{% if recent_transactions %} -
-

Transaction History

-
-
Ticker Qty PriceWeek Change Mkt Value
{{ stock.stock_code }} + + {{ stock.stock_code }} + + {{ stock.quantity|floatformat:0 }}{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}{% 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 %}{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}{% endif %} + {% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}{% endif %} + + {% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}{% endif %} +
- - - - - - - - - - - - - {% for tx in recent_transactions %} - - - - - - - - - - {% endfor %} - -
DatePortfolioActionTickerQtyPriceFee
{{ tx.date|date:"j M Y" }}{{ tx.portfolio.name }}{{ tx.action }}{{ tx.stock_code }}{{ tx.quantity|floatformat:0 }}{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2 }}{% else %}optional{% endif %}{% if tx.fee %}${{ tx.fee|floatformat:2 }}{% else %}{% endif %}
-
-
-{% 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 } }, + }, }, }, }); diff --git a/invest/views.py b/invest/views.py index d322b11..3686f8a 100644 --- a/invest/views.py +++ b/invest/views.py @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index c044730..797e426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "cryptography>=42.0.0", "kubernetes>=29.0.0", "maxminddb>=3.1.1", - "yfinance>=0.2.66", ] [build-system] diff --git a/tests/test_invest_api.py b/tests/test_invest_api.py deleted file mode 100644 index 1858bf5..0000000 --- a/tests/test_invest_api.py +++ /dev/null @@ -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 diff --git a/uv.lock b/uv.lock index 8ecf838..d4c63fc 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, -]