mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Revert "feat: add cashflow-adjusted invest dashboard"
This commit is contained in:
+2
-2
@@ -57,8 +57,8 @@ ALLOWED_HOSTS = ['*']
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
# Use SQLITE_DATABASE_PATH for local verification against a copied DB.
|
||||
'NAME': os.environ.get('SQLITE_DATABASE_PATH', BASE_DIR / 'data/db.sqlite3'),
|
||||
# 'NAME': '/app/data/db.sqlite3', # Updated path
|
||||
'NAME': BASE_DIR / 'data/db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-19
@@ -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'),
|
||||
]
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-06-13 13:26
|
||||
# Hand-adjusted so BenchmarkPrice is safe on production DBs that already have the
|
||||
# hot-patched benchmark cache table.
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invest', '0002_remove_transaction_price_per_share_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name='BenchmarkPrice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticker', models.CharField(max_length=20)),
|
||||
('date', models.DateField()),
|
||||
('close', models.DecimalField(decimal_places=6, max_digits=20)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['ticker', 'date'],
|
||||
'unique_together': {('ticker', 'date')},
|
||||
'indexes': [models.Index(fields=['ticker', 'date'], name='invest_benc_ticker_17637a_idx')],
|
||||
},
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
'CREATE TABLE IF NOT EXISTS "invest_benchmarkprice" ('
|
||||
'"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, '
|
||||
'"ticker" varchar(20) NOT NULL, '
|
||||
'"date" date NOT NULL, '
|
||||
'"close" decimal NOT NULL)'
|
||||
),
|
||||
reverse_sql='DROP TABLE IF EXISTS "invest_benchmarkprice"',
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
||||
'"invest_benchmarkprice_ticker_date_uniq" '
|
||||
'ON "invest_benchmarkprice" ("ticker", "date")'
|
||||
),
|
||||
reverse_sql='DROP INDEX IF EXISTS "invest_benchmarkprice_ticker_date_uniq"',
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
'CREATE INDEX IF NOT EXISTS "invest_benc_ticker_17637a_idx" '
|
||||
'ON "invest_benchmarkprice" ("ticker", "date")'
|
||||
),
|
||||
reverse_sql='DROP INDEX IF EXISTS "invest_benc_ticker_17637a_idx"',
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CashFlow',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('flow_type', models.CharField(choices=[('DEPOSIT', 'Deposit'), ('WITHDRAWAL', 'Withdrawal'), ('DIVIDEND', 'Dividend'), ('FEE', 'Fee'), ('INTEREST', 'Interest'), ('TRANSFER_IN', 'Transfer In'), ('TRANSFER_OUT', 'Transfer Out')], max_length=20)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])),
|
||||
('currency', models.CharField(default='USD', max_length=3)),
|
||||
('date', models.DateField()),
|
||||
('source', models.CharField(blank=True, default='', max_length=50)),
|
||||
('note', models.TextField(blank=True, default='')),
|
||||
('confidence', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('1'))])),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='broker_trade_id',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='confidence',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('1'))]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='currency',
|
||||
field=models.CharField(default='USD', max_length=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='fee',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Optional broker fee/commission in transaction currency.', max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='price_per_share',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Optional execution price per share.', max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='source',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='transaction',
|
||||
index=models.Index(fields=['portfolio', 'date'], name='invest_tran_portfol_962776_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='transaction',
|
||||
index=models.Index(fields=['stock_code', 'date'], name='invest_tran_stock_c_90351a_idx'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cashflow',
|
||||
name='portfolio',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cashflows', to='invest.portfolio'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='cashflow',
|
||||
index=models.Index(fields=['portfolio', 'date'], name='invest_cash_portfol_74c6cf_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='cashflow',
|
||||
index=models.Index(fields=['flow_type', 'date'], name='invest_cash_flow_ty_53c969_idx'),
|
||||
),
|
||||
]
|
||||
+5
-138
@@ -1,12 +1,10 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class Portfolio(models.Model):
|
||||
"""Represents an investment account/portfolio (e.g., 'MOMO', 'User IBKR')."""
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -18,8 +16,7 @@ class Portfolio(models.Model):
|
||||
|
||||
|
||||
class Stock(models.Model):
|
||||
"""Current holdings for a portfolio. Quantity only — prices are fetched on demand."""
|
||||
|
||||
"""Current holdings for a portfolio. Quantity only — no cost tracking."""
|
||||
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='stocks')
|
||||
stock_code = models.CharField(max_length=20, help_text="Stock ticker, e.g. 'NVDA', '9988.HK'")
|
||||
quantity = models.DecimalField(
|
||||
@@ -38,13 +35,7 @@ class Stock(models.Model):
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
"""
|
||||
Buy/sell event log.
|
||||
|
||||
Price/currency/fee are intentionally optional: broker screenshots and AI/OCR syncs often
|
||||
only provide ticker + quantity. When present, these fields enable cost basis and P&L.
|
||||
"""
|
||||
|
||||
"""Buy/sell event log. No price stored — only quantity changes tracked."""
|
||||
ACTION_BUY = 'BUY'
|
||||
ACTION_SELL = 'SELL'
|
||||
ACTION_CHOICES = [(ACTION_BUY, 'Buy'), (ACTION_SELL, 'Sell')]
|
||||
@@ -57,124 +48,18 @@ class Transaction(models.Model):
|
||||
decimal_places=6,
|
||||
validators=[MinValueValidator(Decimal('0.000001'))],
|
||||
)
|
||||
price_per_share = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0'))],
|
||||
help_text='Optional execution price per share.',
|
||||
)
|
||||
currency = models.CharField(max_length=3, default='USD')
|
||||
fee = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0'))],
|
||||
help_text='Optional broker fee/commission in transaction currency.',
|
||||
)
|
||||
broker_trade_id = models.CharField(max_length=128, blank=True, default='')
|
||||
source = models.CharField(max_length=50, blank=True, default='')
|
||||
confidence = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('1'))],
|
||||
)
|
||||
date = models.DateField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['portfolio', 'date']),
|
||||
models.Index(fields=['stock_code', 'date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.action} {self.quantity} {self.stock_code} on {self.date}"
|
||||
|
||||
|
||||
class CashFlow(models.Model):
|
||||
"""External/internal cash ledger used for cash-flow-adjusted performance."""
|
||||
|
||||
FLOW_DEPOSIT = 'DEPOSIT'
|
||||
FLOW_WITHDRAWAL = 'WITHDRAWAL'
|
||||
FLOW_DIVIDEND = 'DIVIDEND'
|
||||
FLOW_FEE = 'FEE'
|
||||
FLOW_INTEREST = 'INTEREST'
|
||||
FLOW_TRANSFER_IN = 'TRANSFER_IN'
|
||||
FLOW_TRANSFER_OUT = 'TRANSFER_OUT'
|
||||
|
||||
FLOW_CHOICES = [
|
||||
(FLOW_DEPOSIT, 'Deposit'),
|
||||
(FLOW_WITHDRAWAL, 'Withdrawal'),
|
||||
(FLOW_DIVIDEND, 'Dividend'),
|
||||
(FLOW_FEE, 'Fee'),
|
||||
(FLOW_INTEREST, 'Interest'),
|
||||
(FLOW_TRANSFER_IN, 'Transfer In'),
|
||||
(FLOW_TRANSFER_OUT, 'Transfer Out'),
|
||||
]
|
||||
|
||||
EXTERNAL_POSITIVE = {FLOW_DEPOSIT, FLOW_TRANSFER_IN}
|
||||
EXTERNAL_NEGATIVE = {FLOW_WITHDRAWAL, FLOW_TRANSFER_OUT}
|
||||
VALUE_POSITIVE = {FLOW_DEPOSIT, FLOW_TRANSFER_IN, FLOW_DIVIDEND, FLOW_INTEREST}
|
||||
VALUE_NEGATIVE = {FLOW_WITHDRAWAL, FLOW_TRANSFER_OUT, FLOW_FEE}
|
||||
|
||||
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='cashflows')
|
||||
flow_type = models.CharField(max_length=20, choices=FLOW_CHOICES)
|
||||
amount = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(Decimal('0.01'))],
|
||||
)
|
||||
currency = models.CharField(max_length=3, default='USD')
|
||||
date = models.DateField()
|
||||
source = models.CharField(max_length=50, blank=True, default='')
|
||||
note = models.TextField(blank=True, default='')
|
||||
confidence = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('1'))],
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['portfolio', 'date']),
|
||||
models.Index(fields=['flow_type', 'date']),
|
||||
]
|
||||
|
||||
@property
|
||||
def signed_amount(self) -> Decimal:
|
||||
if self.flow_type in self.VALUE_NEGATIVE:
|
||||
return -self.amount
|
||||
return self.amount
|
||||
|
||||
@property
|
||||
def external_signed_amount(self) -> Decimal:
|
||||
if self.flow_type in self.EXTERNAL_POSITIVE:
|
||||
return self.amount
|
||||
if self.flow_type in self.EXTERNAL_NEGATIVE:
|
||||
return -self.amount
|
||||
return Decimal('0')
|
||||
|
||||
@property
|
||||
def is_external(self) -> bool:
|
||||
return self.flow_type in self.EXTERNAL_POSITIVE.union(self.EXTERNAL_NEGATIVE)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.flow_type} {self.amount} {self.currency} ({self.portfolio.name}) on {self.date}"
|
||||
|
||||
|
||||
class PortfolioSnapshot(models.Model):
|
||||
"""Periodic total-value snapshot per portfolio."""
|
||||
|
||||
"""Weekly total-value snapshot per portfolio, captured Saturday 8 AM."""
|
||||
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='snapshots')
|
||||
captured_at = models.DateTimeField()
|
||||
total_value = models.DecimalField(max_digits=20, decimal_places=2)
|
||||
@@ -187,21 +72,3 @@ class PortfolioSnapshot(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.portfolio.name} @ {self.captured_at:%Y-%m-%d %H:%M}: ${self.total_value}"
|
||||
|
||||
|
||||
class BenchmarkPrice(models.Model):
|
||||
"""Daily close for benchmark tickers (QQQ, SPY, etc.) cached from market data."""
|
||||
|
||||
ticker = models.CharField(max_length=20)
|
||||
date = models.DateField()
|
||||
close = models.DecimalField(max_digits=20, decimal_places=6)
|
||||
|
||||
class Meta:
|
||||
unique_together = [('ticker', 'date')]
|
||||
indexes = [
|
||||
models.Index(fields=['ticker', 'date']),
|
||||
]
|
||||
ordering = ['ticker', 'date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.ticker} {self.date}: ${self.close}"
|
||||
|
||||
+21
-78
@@ -1,6 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
|
||||
from .models import Portfolio, Stock, Transaction
|
||||
|
||||
|
||||
class StockSerializer(serializers.ModelSerializer):
|
||||
@@ -16,79 +15,10 @@ class TransactionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
'id',
|
||||
'portfolio',
|
||||
'action',
|
||||
'action_display',
|
||||
'stock_code',
|
||||
'quantity',
|
||||
'price_per_share',
|
||||
'currency',
|
||||
'fee',
|
||||
'broker_trade_id',
|
||||
'source',
|
||||
'confidence',
|
||||
'date',
|
||||
'created_at',
|
||||
'id', 'portfolio', 'action', 'action_display',
|
||||
'stock_code', 'quantity', 'date', 'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
extra_kwargs = {
|
||||
'price_per_share': {'required': False, 'allow_null': True},
|
||||
'fee': {'required': False, 'allow_null': True},
|
||||
'currency': {'required': False},
|
||||
'broker_trade_id': {'required': False, 'allow_blank': True},
|
||||
'source': {'required': False, 'allow_blank': True},
|
||||
'confidence': {'required': False, 'allow_null': True},
|
||||
}
|
||||
|
||||
|
||||
class CashFlowSerializer(serializers.ModelSerializer):
|
||||
flow_type_display = serializers.CharField(source='get_flow_type_display', read_only=True)
|
||||
signed_amount = serializers.DecimalField(max_digits=20, decimal_places=2, read_only=True)
|
||||
external_signed_amount = serializers.DecimalField(max_digits=20, decimal_places=2, read_only=True)
|
||||
is_external = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CashFlow
|
||||
fields = [
|
||||
'id',
|
||||
'portfolio',
|
||||
'flow_type',
|
||||
'flow_type_display',
|
||||
'amount',
|
||||
'signed_amount',
|
||||
'external_signed_amount',
|
||||
'is_external',
|
||||
'currency',
|
||||
'date',
|
||||
'source',
|
||||
'note',
|
||||
'confidence',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
extra_kwargs = {
|
||||
'currency': {'required': False},
|
||||
'source': {'required': False, 'allow_blank': True},
|
||||
'note': {'required': False, 'allow_blank': True},
|
||||
'confidence': {'required': False, 'allow_null': True},
|
||||
}
|
||||
|
||||
|
||||
class PortfolioSnapshotSerializer(serializers.ModelSerializer):
|
||||
portfolio_name = serializers.CharField(source='portfolio.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PortfolioSnapshot
|
||||
fields = ['id', 'portfolio', 'portfolio_name', 'captured_at', 'total_value']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class BenchmarkPriceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BenchmarkPrice
|
||||
fields = ['id', 'ticker', 'date', 'close']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class PortfolioSerializer(serializers.ModelSerializer):
|
||||
@@ -112,6 +42,10 @@ class PortfolioListSerializer(serializers.ModelSerializer):
|
||||
return obj.stocks.count()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI Update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AIHoldingInputSerializer(serializers.Serializer):
|
||||
stock_code = serializers.CharField()
|
||||
quantity = serializers.FloatField()
|
||||
@@ -123,15 +57,19 @@ class AIUpdateSerializer(serializers.Serializer):
|
||||
reset = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------+
|
||||
# Holdings (with real-time prices) |
|
||||
# -------------------------------------------------------------------------+
|
||||
|
||||
class HoldingSerializer(serializers.Serializer):
|
||||
stock_code = serializers.CharField()
|
||||
quantity = serializers.FloatField()
|
||||
avg_cost = serializers.FloatField()
|
||||
current_price = serializers.FloatField()
|
||||
current_value = serializers.FloatField()
|
||||
ref_price = serializers.FloatField(required=False, allow_null=True)
|
||||
price_change = serializers.FloatField(required=False, allow_null=True)
|
||||
price_change_pct = serializers.FloatField(required=False, allow_null=True)
|
||||
value_change = serializers.FloatField(required=False, allow_null=True)
|
||||
unrealized_pnl = serializers.FloatField()
|
||||
unrealized_pnl_pct = serializers.FloatField()
|
||||
|
||||
|
||||
class PortfolioHoldingsSerializer(serializers.Serializer):
|
||||
@@ -139,9 +77,14 @@ class PortfolioHoldingsSerializer(serializers.Serializer):
|
||||
portfolio_name = serializers.CharField()
|
||||
holdings = HoldingSerializer(many=True)
|
||||
total_value = serializers.FloatField()
|
||||
total_cost = serializers.FloatField()
|
||||
total_pnl = serializers.FloatField()
|
||||
total_pnl_pct = serializers.FloatField()
|
||||
|
||||
|
||||
class AIUpdateResultSerializer(serializers.Serializer):
|
||||
stock_code = serializers.CharField()
|
||||
quantity = serializers.FloatField()
|
||||
created = serializers.BooleanField()
|
||||
avg_cost = serializers.FloatField()
|
||||
stock_created = serializers.BooleanField()
|
||||
tx_status = serializers.CharField()
|
||||
|
||||
+171
-522
@@ -1,71 +1,31 @@
|
||||
"""
|
||||
Service layer for the invest app.
|
||||
|
||||
Design goals:
|
||||
- Keep ticker/quantity sync simple for AI/OCR workflows.
|
||||
- Treat transaction price/currency/fee as optional.
|
||||
- Separate account-value growth from cash-flow-adjusted investment return.
|
||||
Prices fetched from Yahoo Finance on demand via yfinance.
|
||||
No cost basis or P&L tracking.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock
|
||||
from .models import Portfolio, Stock, PortfolioSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price cache
|
||||
# In-process price cache (5 min TTL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_price_cache: dict[str, tuple[float, datetime]] = {}
|
||||
_historical_price_cache: dict[str, tuple[Optional[float], datetime]] = {}
|
||||
_chart_cache: dict = {}
|
||||
_PRICE_CACHE_TTL_SECONDS = 300
|
||||
_HISTORICAL_CACHE_TTL_SECONDS = 3600
|
||||
_CHART_CACHE_TTL = 900
|
||||
|
||||
|
||||
SEMI_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM'}
|
||||
AI_CLOUD_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM', 'NET', 'DDOG', 'GOOG', 'GOOGL', 'MSFT', 'AMZN'}
|
||||
|
||||
|
||||
def _to_float(value) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _as_date(value) -> Optional[date_cls]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return timezone.localtime(value).date() if timezone.is_aware(value) else value.date()
|
||||
if hasattr(value, 'date') and not isinstance(value, date_cls):
|
||||
return value.date()
|
||||
if isinstance(value, date_cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return date_cls.fromisoformat(value)
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_yfinance_price(stock_code: str) -> Optional[float]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
ticker = yf.Ticker(stock_code)
|
||||
hist = ticker.history(period="1d")
|
||||
if hist.empty:
|
||||
@@ -78,7 +38,6 @@ def _get_yfinance_price(stock_code: str) -> Optional[float]:
|
||||
|
||||
def get_current_price(stock_code: str) -> Optional[float]:
|
||||
now = datetime.now()
|
||||
stock_code = stock_code.upper()
|
||||
cached = _price_cache.get(stock_code)
|
||||
if cached:
|
||||
price, cached_at = cached
|
||||
@@ -94,117 +53,23 @@ def get_current_price(stock_code: str) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_historical_price(stock_code: str, ref_date) -> Optional[float]:
|
||||
"""Return the close on or before ref_date, using BenchmarkPrice then yfinance fallback."""
|
||||
ref_date = _as_date(ref_date)
|
||||
if not ref_date:
|
||||
return None
|
||||
stock_code = stock_code.upper()
|
||||
cache_key = f"{stock_code}:{ref_date.isoformat()}"
|
||||
now = datetime.now()
|
||||
|
||||
# Prefer explicit DB fixtures/cache rows over in-process cache. Tests and manual backfills
|
||||
# may create BenchmarkPrice rows after a previous best-effort yfinance lookup.
|
||||
db_price = (
|
||||
BenchmarkPrice.objects.filter(ticker=stock_code, date__lte=ref_date)
|
||||
.order_by('-date')
|
||||
.values_list('close', flat=True)
|
||||
.first()
|
||||
)
|
||||
if db_price is not None:
|
||||
price = float(db_price)
|
||||
_historical_price_cache[cache_key] = (price, now)
|
||||
return price
|
||||
|
||||
cached = _historical_price_cache.get(cache_key)
|
||||
if cached and cached[0] is not None and (now - cached[1]).total_seconds() < _HISTORICAL_CACHE_TTL_SECONDS:
|
||||
return cached[0]
|
||||
|
||||
price = None
|
||||
try:
|
||||
import yfinance as yf
|
||||
|
||||
start = ref_date - timedelta(days=7)
|
||||
end = ref_date + timedelta(days=1)
|
||||
hist = yf.Ticker(stock_code).history(start=start.isoformat(), end=end.isoformat())
|
||||
if not hist.empty:
|
||||
price = float(hist["Close"].iloc[-1])
|
||||
BenchmarkPrice.objects.update_or_create(
|
||||
ticker=stock_code,
|
||||
date=hist.index[-1].date() if hasattr(hist.index[-1], 'date') else ref_date,
|
||||
defaults={'close': Decimal(str(round(price, 6)))},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("historical price failed for %s @ %s: %s", stock_code, ref_date, exc)
|
||||
|
||||
_historical_price_cache[cache_key] = (price, now)
|
||||
return price
|
||||
|
||||
|
||||
def refresh_benchmark_prices(tickers: Iterable[str] = ('SPY', 'QQQ'), days: int = 540) -> int:
|
||||
"""Best-effort benchmark cache refresh. Returns number of rows upserted."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
except Exception as exc:
|
||||
logger.warning("yfinance unavailable for benchmark refresh: %s", exc)
|
||||
return 0
|
||||
|
||||
end = timezone.now().date() + timedelta(days=1)
|
||||
start = end - timedelta(days=days)
|
||||
count = 0
|
||||
for ticker in tickers:
|
||||
try:
|
||||
hist = yf.Ticker(ticker).history(start=start.isoformat(), end=end.isoformat())
|
||||
rows = []
|
||||
for d, v in hist['Close'].items():
|
||||
row_date = d.date() if hasattr(d, 'date') else d
|
||||
rows.append(BenchmarkPrice(ticker=ticker.upper(), date=row_date, close=Decimal(str(round(float(v), 6)))))
|
||||
if rows:
|
||||
BenchmarkPrice.objects.bulk_create(
|
||||
rows,
|
||||
update_conflicts=True,
|
||||
unique_fields=['ticker', 'date'],
|
||||
update_fields=['close'],
|
||||
)
|
||||
count += len(rows)
|
||||
except Exception as exc:
|
||||
logger.warning("benchmark refresh failed for %s: %s", ticker, exc)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portfolio values
|
||||
# Portfolio value (live prices, no cost tracking)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
|
||||
"""Return live holdings with current prices and optional change vs reference_date."""
|
||||
def get_portfolio_value(portfolio: Portfolio) -> dict:
|
||||
"""Return live holdings with current prices and total value."""
|
||||
holdings = []
|
||||
total_value = Decimal('0')
|
||||
|
||||
for stock in portfolio.stocks.filter(quantity__gt=0):
|
||||
ticker = stock.stock_code.upper()
|
||||
price = get_current_price(ticker) or 0.0
|
||||
price = get_current_price(stock.stock_code) or 0.0
|
||||
value = Decimal(str(price)) * stock.quantity
|
||||
ref_price = _get_historical_price(ticker, reference_date) if reference_date else None
|
||||
|
||||
price_change = None
|
||||
price_change_pct = None
|
||||
value_change = None
|
||||
if price and ref_price and ref_price > 0:
|
||||
price_change = round(price - ref_price, 4)
|
||||
price_change_pct = round((price_change / ref_price) * 100, 2)
|
||||
value_change = round(price_change * float(stock.quantity), 2)
|
||||
|
||||
holdings.append({
|
||||
'stock_code': ticker,
|
||||
'stock_code': stock.stock_code,
|
||||
'quantity': float(stock.quantity),
|
||||
'current_price': price,
|
||||
'current_value': float(value),
|
||||
'ref_price': ref_price,
|
||||
'price_change': price_change,
|
||||
'price_change_pct': price_change_pct,
|
||||
'value_change': value_change,
|
||||
})
|
||||
total_value += value
|
||||
|
||||
@@ -216,117 +81,71 @@ def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_all_holdings(reference_date=None) -> list[dict]:
|
||||
palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
|
||||
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
|
||||
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
|
||||
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
|
||||
]
|
||||
|
||||
result = []
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
data = get_portfolio_value(portfolio, reference_date=reference_date)
|
||||
result.append({
|
||||
'portfolio': portfolio,
|
||||
'colors': palette[idx % len(palette)],
|
||||
'holdings': data['holdings'],
|
||||
'total_value': data['total_value'],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _snapshot_asof(portfolio: Portfolio, target_date) -> Optional[float]:
|
||||
target_date = _as_date(target_date)
|
||||
if not target_date:
|
||||
return None
|
||||
snap = (
|
||||
PortfolioSnapshot.objects.filter(portfolio=portfolio, captured_at__date__lte=target_date)
|
||||
.order_by('-captured_at')
|
||||
.first()
|
||||
)
|
||||
return float(snap.total_value) if snap else None
|
||||
|
||||
|
||||
def get_total_value_asof(target_date=None, live_if_today: bool = True) -> Optional[float]:
|
||||
target_date = _as_date(target_date)
|
||||
today = timezone.now().date()
|
||||
portfolios = list(Portfolio.objects.prefetch_related('stocks').all())
|
||||
if target_date is None or (live_if_today and target_date == today):
|
||||
total = sum(get_portfolio_value(p)['total_value'] for p in portfolios)
|
||||
return float(total)
|
||||
|
||||
values = [_snapshot_asof(p, target_date) for p in portfolios]
|
||||
values = [v for v in values if v is not None]
|
||||
if not values:
|
||||
return None
|
||||
return float(sum(values))
|
||||
|
||||
|
||||
def _distinct_snapshot_dates() -> list[date_cls]:
|
||||
days = []
|
||||
for dt in PortfolioSnapshot.objects.values_list('captured_at', flat=True).order_by('captured_at'):
|
||||
day = _as_date(dt)
|
||||
if day and day not in days:
|
||||
days.append(day)
|
||||
return days
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weekly overview
|
||||
# Weekly snapshot overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_snapshot_total(date) -> Optional[float]:
|
||||
result = PortfolioSnapshot.objects.filter(
|
||||
captured_at__date=date
|
||||
).aggregate(total=Sum('total_value'))['total']
|
||||
return float(result) if result is not None else None
|
||||
|
||||
|
||||
def get_weekly_overview() -> dict:
|
||||
"""
|
||||
Compute overview from the latest snapshot and the prior snapshot at least 5 days earlier.
|
||||
Uses as-of per-portfolio lookups to avoid duplicate/mixed-market snapshot dates double counting.
|
||||
Compute overview from the two most recent Saturday snapshots.
|
||||
Returns totals, week-over-week change, and per-portfolio rows.
|
||||
"""
|
||||
latest_ts = PortfolioSnapshot.objects.order_by('-captured_at').values_list('captured_at', flat=True).first()
|
||||
today = timezone.now().date()
|
||||
# Find the 2 most recent distinct snapshot dates
|
||||
seen_days: list = []
|
||||
for dt in (PortfolioSnapshot.objects
|
||||
.values_list('captured_at', flat=True)
|
||||
.order_by('-captured_at')):
|
||||
day = dt.date() if hasattr(dt, 'date') else dt
|
||||
if day not in seen_days:
|
||||
seen_days.append(day)
|
||||
if len(seen_days) == 2:
|
||||
break
|
||||
|
||||
if latest_ts:
|
||||
this_week_date = _as_date(latest_ts)
|
||||
snapshots_are_stale = this_week_date < today
|
||||
else:
|
||||
this_week_date = today
|
||||
snapshots_are_stale = True
|
||||
this_week_date = seen_days[0] if len(seen_days) >= 1 else None
|
||||
last_week_date = seen_days[1] if len(seen_days) >= 2 else None
|
||||
|
||||
cutoff = this_week_date - timedelta(days=5)
|
||||
prev_ts = (
|
||||
PortfolioSnapshot.objects.filter(captured_at__date__lte=cutoff)
|
||||
.order_by('-captured_at')
|
||||
.values_list('captured_at', flat=True)
|
||||
.first()
|
||||
)
|
||||
last_week_date = _as_date(prev_ts) if prev_ts else None
|
||||
|
||||
this_week_total = get_total_value_asof(this_week_date if not snapshots_are_stale else today)
|
||||
last_week_total = get_total_value_asof(last_week_date, live_if_today=False) if last_week_date else None
|
||||
this_week_total = _get_snapshot_total(this_week_date) if this_week_date else None
|
||||
last_week_total = _get_snapshot_total(last_week_date) if last_week_date else None
|
||||
|
||||
week_gain = None
|
||||
week_change_pct = None
|
||||
if this_week_total is not None and last_week_total and last_week_total > 0:
|
||||
if this_week_total is not None and last_week_total is not None and last_week_total > 0:
|
||||
week_gain = this_week_total - last_week_total
|
||||
week_change_pct = round((week_gain / last_week_total) * 100, 2)
|
||||
|
||||
palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
# Per-portfolio breakdown
|
||||
_palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
|
||||
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
|
||||
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
|
||||
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
|
||||
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
|
||||
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
|
||||
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
|
||||
]
|
||||
|
||||
portfolio_rows = []
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
this_val = get_portfolio_value(portfolio)['total_value'] if snapshots_are_stale else _snapshot_asof(portfolio, this_week_date)
|
||||
last_val = _snapshot_asof(portfolio, last_week_date) if last_week_date else None
|
||||
def _snap(date):
|
||||
if not date:
|
||||
return None
|
||||
s = PortfolioSnapshot.objects.filter(
|
||||
portfolio=portfolio, captured_at__date=date
|
||||
).first()
|
||||
return float(s.total_value) if s else None
|
||||
|
||||
this_val = _snap(this_week_date)
|
||||
last_val = _snap(last_week_date)
|
||||
|
||||
change = change_pct = None
|
||||
if this_val is not None and last_val and last_val > 0:
|
||||
if this_val is not None and last_val is not None and last_val > 0:
|
||||
change = this_val - last_val
|
||||
change_pct = round((change / last_val) * 100, 2)
|
||||
|
||||
portfolio_rows.append({
|
||||
'portfolio': portfolio,
|
||||
'this_week_value': this_val,
|
||||
@@ -334,7 +153,7 @@ def get_weekly_overview() -> dict:
|
||||
'change': change,
|
||||
'change_pct': change_pct,
|
||||
'position_count': portfolio.stocks.filter(quantity__gt=0).count(),
|
||||
'colors': palette[idx % len(palette)],
|
||||
'colors': _palette[idx % len(_palette)],
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -350,236 +169,51 @@ def get_weekly_overview() -> dict:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cash-flow adjusted performance
|
||||
# Holdings sync (AI / manual)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_all_holdings() -> list[dict]:
|
||||
"""
|
||||
Return live holdings for every portfolio, grouped for dashboard display.
|
||||
Each entry: portfolio, portfolio_color_class, holdings (list), total_value
|
||||
"""
|
||||
# Assign a distinct Tailwind color set per portfolio (cycled if more than defined)
|
||||
palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
|
||||
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
|
||||
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
|
||||
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
|
||||
]
|
||||
|
||||
def _external_cashflows(start=None, end=None, include_start: bool = False):
|
||||
qs = CashFlow.objects.all()
|
||||
if start:
|
||||
start_date = _as_date(start)
|
||||
qs = qs.filter(date__gte=start_date) if include_start else qs.filter(date__gt=start_date)
|
||||
if end:
|
||||
qs = qs.filter(date__lte=_as_date(end))
|
||||
return qs.order_by('date', 'created_at')
|
||||
|
||||
|
||||
def _sum_external_cashflows(start=None, end=None, include_start: bool = False) -> Decimal:
|
||||
total = Decimal('0')
|
||||
for flow in _external_cashflows(start=start, end=end, include_start=include_start):
|
||||
total += flow.external_signed_amount
|
||||
return total
|
||||
|
||||
|
||||
def get_net_external_cash_flow(start=None, end=None) -> float:
|
||||
"""All external deposits/transfers in minus withdrawals/transfers out."""
|
||||
return round(float(_sum_external_cashflows(start=start, end=end, include_start=True)), 2)
|
||||
|
||||
|
||||
def _first_performance_date() -> Optional[date_cls]:
|
||||
snapshot_date = PortfolioSnapshot.objects.order_by('captured_at').values_list('captured_at', flat=True).first()
|
||||
flow_date = CashFlow.objects.order_by('date').values_list('date', flat=True).first()
|
||||
candidates = [_as_date(v) for v in (snapshot_date, flow_date) if v]
|
||||
return min(candidates) if candidates else None
|
||||
|
||||
|
||||
def _xirr(cashflows: list[tuple[date_cls, Decimal]]) -> Optional[float]:
|
||||
if not cashflows:
|
||||
return None
|
||||
if not any(amount < 0 for _, amount in cashflows) or not any(amount > 0 for _, amount in cashflows):
|
||||
return None
|
||||
start = cashflows[0][0]
|
||||
|
||||
def npv(rate: float) -> float:
|
||||
total = 0.0
|
||||
for flow_date, amount in cashflows:
|
||||
years = (flow_date - start).days / 365.0
|
||||
total += float(amount) / ((1 + rate) ** years)
|
||||
return total
|
||||
|
||||
low, high = -0.9999, 10.0
|
||||
try:
|
||||
for _ in range(100):
|
||||
mid = (low + high) / 2
|
||||
val = npv(mid)
|
||||
if abs(val) < 1e-7:
|
||||
return round(mid, 6)
|
||||
if val > 0:
|
||||
low = mid
|
||||
else:
|
||||
high = mid
|
||||
return round((low + high) / 2, 6)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _benchmark_same_cashflow(ticker: str, start: date_cls, end: date_cls, start_value: float, flows) -> Optional[dict]:
|
||||
ticker = ticker.upper()
|
||||
start_price = _get_historical_price(ticker, start)
|
||||
end_price = _get_historical_price(ticker, end)
|
||||
if not start_price or not end_price:
|
||||
return None
|
||||
|
||||
units = Decimal(str(start_value)) / Decimal(str(start_price)) if start_value else Decimal('0')
|
||||
net_external = Decimal('0')
|
||||
for flow in flows:
|
||||
price = _get_historical_price(ticker, flow.date)
|
||||
if not price:
|
||||
continue
|
||||
amount = flow.external_signed_amount
|
||||
net_external += amount
|
||||
units += amount / Decimal(str(price))
|
||||
|
||||
end_value = units * Decimal(str(end_price))
|
||||
cash_adjusted_gain = end_value - Decimal(str(start_value)) - net_external
|
||||
capital_base = Decimal(str(start_value)) + sum(
|
||||
f.external_signed_amount for f in flows if f.external_signed_amount > 0
|
||||
)
|
||||
return {
|
||||
'ticker': ticker,
|
||||
'start_price': round(start_price, 4),
|
||||
'end_price': round(end_price, 4),
|
||||
'end_value': round(float(end_value), 2),
|
||||
'cash_adjusted_gain': round(float(cash_adjusted_gain), 2),
|
||||
'simple_return': round(float(cash_adjusted_gain / capital_base), 6) if capital_base > 0 else None,
|
||||
}
|
||||
|
||||
|
||||
def get_cashflow_adjusted_performance(start=None, end=None, benchmark_tickers: Iterable[str] = ('QQQ', 'SPY')) -> dict:
|
||||
end_date = _as_date(end) or timezone.now().date()
|
||||
explicit_start = start is not None
|
||||
start_date = _as_date(start) or _first_performance_date() or end_date
|
||||
|
||||
start_value = get_total_value_asof(start_date, live_if_today=False)
|
||||
if start_value is None:
|
||||
start_value = 0.0
|
||||
end_value = get_total_value_asof(end_date)
|
||||
if end_value is None:
|
||||
end_value = 0.0
|
||||
|
||||
include_start_flows = not explicit_start and start_value == 0
|
||||
flows = list(_external_cashflows(start=start_date, end=end_date, include_start=include_start_flows))
|
||||
net_external = sum((flow.external_signed_amount for flow in flows), Decimal('0'))
|
||||
positive_external = sum((flow.external_signed_amount for flow in flows if flow.external_signed_amount > 0), Decimal('0'))
|
||||
cash_adjusted_gain = Decimal(str(end_value)) - Decimal(str(start_value)) - net_external
|
||||
capital_base = Decimal(str(start_value)) + positive_external
|
||||
simple_return = cash_adjusted_gain / capital_base if capital_base > 0 else None
|
||||
|
||||
xirr_flows = [(start_date, -Decimal(str(start_value)))] if start_value else []
|
||||
for flow in flows:
|
||||
xirr_flows.append((flow.date, -flow.external_signed_amount))
|
||||
xirr_flows.append((end_date, Decimal(str(end_value))))
|
||||
|
||||
benchmarks = {}
|
||||
for ticker in benchmark_tickers:
|
||||
bench = _benchmark_same_cashflow(ticker, start_date, end_date, start_value, flows)
|
||||
if bench:
|
||||
benchmarks[ticker.upper()] = bench
|
||||
|
||||
return {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat(),
|
||||
'start_value': round(start_value, 2),
|
||||
'end_value': round(end_value, 2),
|
||||
'net_external_cash_flow': round(float(net_external), 2),
|
||||
'positive_external_cash_flow': round(float(positive_external), 2),
|
||||
'cash_adjusted_gain': round(float(cash_adjusted_gain), 2),
|
||||
'simple_return': round(float(simple_return), 6) if simple_return is not None else None,
|
||||
'money_weighted_return': _xirr(xirr_flows),
|
||||
'cashflows': [
|
||||
{
|
||||
'id': flow.id,
|
||||
'portfolio_id': flow.portfolio_id,
|
||||
'flow_type': flow.flow_type,
|
||||
'date': flow.date.isoformat(),
|
||||
'amount': float(flow.amount),
|
||||
'external_signed_amount': float(flow.external_signed_amount),
|
||||
'currency': flow.currency,
|
||||
}
|
||||
for flow in flows
|
||||
],
|
||||
'benchmarks': benchmarks,
|
||||
}
|
||||
result = []
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
colors = palette[idx % len(palette)]
|
||||
data = get_portfolio_value(portfolio)
|
||||
result.append({
|
||||
'portfolio': portfolio,
|
||||
'colors': colors,
|
||||
'holdings': data['holdings'],
|
||||
'total_value': data['total_value'],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk and agent summary
|
||||
# Performance chart data (cumulative % from first snapshot + benchmarks)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_risk_summary() -> dict:
|
||||
holdings = []
|
||||
for group in get_all_holdings():
|
||||
for holding in group['holdings']:
|
||||
holdings.append({
|
||||
'portfolio_id': group['portfolio'].id,
|
||||
'portfolio_name': group['portfolio'].name,
|
||||
**holding,
|
||||
})
|
||||
|
||||
total_value = sum(h['current_value'] for h in holdings)
|
||||
holdings.sort(key=lambda h: h['current_value'], reverse=True)
|
||||
|
||||
for holding in holdings:
|
||||
holding['weight'] = round(holding['current_value'] / total_value, 6) if total_value else 0
|
||||
|
||||
top_1 = holdings[0]['weight'] if holdings else 0
|
||||
top_3 = sum(h['weight'] for h in holdings[:3])
|
||||
top_5 = sum(h['weight'] for h in holdings[:5])
|
||||
|
||||
by_ticker = defaultdict(float)
|
||||
for holding in holdings:
|
||||
by_ticker[holding['stock_code']] += holding['current_value']
|
||||
ticker_weights = {
|
||||
ticker: value / total_value for ticker, value in by_ticker.items()
|
||||
} if total_value else {}
|
||||
|
||||
semi_weight = sum(weight for ticker, weight in ticker_weights.items() if ticker in SEMI_TICKERS)
|
||||
ai_cloud_weight = sum(weight for ticker, weight in ticker_weights.items() if ticker in AI_CLOUD_TICKERS)
|
||||
|
||||
concentration_level = 'LOW'
|
||||
if top_1 >= 0.25 or top_5 >= 0.70:
|
||||
concentration_level = 'HIGH'
|
||||
elif top_3 >= 0.50 or top_5 >= 0.55:
|
||||
concentration_level = 'MEDIUM'
|
||||
|
||||
return {
|
||||
'total_value': round(total_value, 2),
|
||||
'position_count': len(holdings),
|
||||
'top_1_weight': round(top_1, 6),
|
||||
'top_3_weight': round(top_3, 6),
|
||||
'top_5_weight': round(top_5, 6),
|
||||
'concentration_level': concentration_level,
|
||||
'max_position': holdings[0] if holdings else None,
|
||||
'top_positions': holdings[:10],
|
||||
'theme_exposure': {
|
||||
'semiconductors': round(semi_weight, 6),
|
||||
'ai_cloud': round(ai_cloud_weight, 6),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_agent_summary() -> dict:
|
||||
total_value = get_total_value_asof()
|
||||
net_external_all_time = _sum_external_cashflows()
|
||||
performance = get_cashflow_adjusted_performance()
|
||||
risk = get_risk_summary()
|
||||
return {
|
||||
'as_of': timezone.now().isoformat(),
|
||||
'portfolio_count': Portfolio.objects.count(),
|
||||
'total_value': round(total_value or 0, 2),
|
||||
'net_external_cash_flow': round(float(net_external_all_time), 2),
|
||||
'performance': performance,
|
||||
'risk': risk,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Performance chart data (snapshot value % vs benchmarks)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-process cache — benchmarks don't need to refresh every page load
|
||||
_chart_cache: dict = {}
|
||||
_CHART_CACHE_TTL = 900 # 15 minutes
|
||||
|
||||
|
||||
def get_performance_chart_data() -> Optional[str]:
|
||||
"""
|
||||
Build Chart.js-ready JSON with cumulative % return from the earliest snapshot.
|
||||
Base week = 0%. Each portfolio gets a series; S&P 500 (SPY) and QQQ added as benchmarks.
|
||||
Returns a JSON string (safe to pass directly to the template) or None if no snapshots.
|
||||
"""
|
||||
now = datetime.now()
|
||||
cached = _chart_cache.get('performance')
|
||||
if cached:
|
||||
@@ -593,50 +227,43 @@ def get_performance_chart_data() -> Optional[str]:
|
||||
|
||||
|
||||
def _build_performance_chart_data() -> Optional[str]:
|
||||
all_snaps = list(PortfolioSnapshot.objects.select_related('portfolio').order_by('captured_at'))
|
||||
if not all_snaps:
|
||||
# Collect all distinct snapshot dates in ascending order
|
||||
all_dates: list = []
|
||||
for dt in (PortfolioSnapshot.objects
|
||||
.values_list('captured_at', flat=True)
|
||||
.order_by('captured_at')):
|
||||
day = dt.date() if hasattr(dt, 'date') else dt
|
||||
if day not in all_dates:
|
||||
all_dates.append(day)
|
||||
|
||||
if not all_dates:
|
||||
return None
|
||||
|
||||
portfolio_weekly: dict[int, dict[tuple[int, int], tuple[date_cls, float]]] = {}
|
||||
for snap in all_snaps:
|
||||
day = _as_date(snap.captured_at)
|
||||
key = day.isocalendar()[:2]
|
||||
portfolio_weekly.setdefault(snap.portfolio_id, {})
|
||||
existing = portfolio_weekly[snap.portfolio_id].get(key)
|
||||
if existing is None or day > existing[0]:
|
||||
portfolio_weekly[snap.portfolio_id][key] = (day, float(snap.total_value))
|
||||
base_date = all_dates[0]
|
||||
|
||||
all_week_keys = sorted({wk for weekly in portfolio_weekly.values() for wk in weekly})
|
||||
if not all_week_keys:
|
||||
return None
|
||||
|
||||
week_label_date = {}
|
||||
for weekly in portfolio_weekly.values():
|
||||
for week, (day, _) in weekly.items():
|
||||
if week not in week_label_date or day > week_label_date[week]:
|
||||
week_label_date[week] = day
|
||||
|
||||
earliest_date = week_label_date[all_week_keys[0]]
|
||||
latest_date = week_label_date[all_week_keys[-1]]
|
||||
refresh_needed = not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=earliest_date).exists()
|
||||
if refresh_needed:
|
||||
refresh_benchmark_prices()
|
||||
|
||||
colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
|
||||
# Per-portfolio cumulative % series
|
||||
portfolio_colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
|
||||
datasets = []
|
||||
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
weekly = portfolio_weekly.get(portfolio.id, {})
|
||||
if not weekly:
|
||||
continue
|
||||
first_week = min(weekly.keys())
|
||||
base_val = weekly[first_week][1]
|
||||
if not base_val:
|
||||
snaps = {
|
||||
(snap.captured_at.date() if hasattr(snap.captured_at, 'date') else snap.captured_at): float(snap.total_value)
|
||||
for snap in PortfolioSnapshot.objects.filter(portfolio=portfolio).order_by('captured_at')
|
||||
}
|
||||
base_val = snaps.get(base_date)
|
||||
if not base_val or base_val == 0:
|
||||
continue
|
||||
|
||||
data_pts = [
|
||||
round((snaps[d] - base_val) / base_val * 100, 2) if d in snaps else None
|
||||
for d in all_dates
|
||||
]
|
||||
color = portfolio_colors[idx % len(portfolio_colors)]
|
||||
datasets.append({
|
||||
'label': portfolio.name,
|
||||
'data': [round((weekly[w][1] - base_val) / base_val * 100, 2) if w in weekly else None for w in all_week_keys],
|
||||
'borderColor': colors[idx % len(colors)],
|
||||
'backgroundColor': colors[idx % len(colors)],
|
||||
'data': data_pts,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': 2,
|
||||
'pointRadius': 5,
|
||||
'pointHoverRadius': 7,
|
||||
@@ -645,42 +272,64 @@ def _build_performance_chart_data() -> Optional[str]:
|
||||
'fill': False,
|
||||
})
|
||||
|
||||
def benchmark_series(ticker: str, label: str, color: str) -> Optional[dict]:
|
||||
base_price = _get_historical_price(ticker, earliest_date)
|
||||
if not base_price:
|
||||
# Benchmark series — start 7 days before base to capture the last trading day prior
|
||||
start_str = (base_date - timedelta(days=7)).isoformat()
|
||||
end_str = (all_dates[-1] + timedelta(days=5)).isoformat()
|
||||
|
||||
def _benchmark(ticker: str, label: str, color: str) -> Optional[dict]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
hist = yf.Ticker(ticker).history(start=start_str, end=end_str)
|
||||
if hist.empty:
|
||||
return None
|
||||
# Build date → close mapping
|
||||
closes = {
|
||||
(d.date() if hasattr(d, 'date') else d): float(v)
|
||||
for d, v in hist['Close'].items()
|
||||
}
|
||||
sorted_trading_days = sorted(closes.keys())
|
||||
|
||||
def closest_close(target):
|
||||
candidates = [td for td in sorted_trading_days if td <= target]
|
||||
return closes[candidates[-1]] if candidates else None
|
||||
|
||||
base_price = closest_close(base_date)
|
||||
if not base_price:
|
||||
return None
|
||||
data_pts = [
|
||||
round((closest_close(d) - base_price) / base_price * 100, 2)
|
||||
if closest_close(d) is not None else None
|
||||
for d in all_dates
|
||||
]
|
||||
return {
|
||||
'label': label,
|
||||
'data': data_pts,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': 1.5,
|
||||
'pointRadius': 3,
|
||||
'pointHoverRadius': 5,
|
||||
'tension': 0.3,
|
||||
'borderDash': [5, 5],
|
||||
'fill': False,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("benchmark %s failed: %s", ticker, exc)
|
||||
return None
|
||||
data = []
|
||||
for week in all_week_keys:
|
||||
price = _get_historical_price(ticker, week_label_date[week])
|
||||
data.append(round((price - base_price) / base_price * 100, 2) if price else None)
|
||||
return {
|
||||
'label': label,
|
||||
'data': data,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': 1.5,
|
||||
'pointRadius': 3,
|
||||
'pointHoverRadius': 5,
|
||||
'tension': 0.3,
|
||||
'borderDash': [5, 5],
|
||||
'fill': False,
|
||||
}
|
||||
|
||||
for item in (benchmark_series('SPY', 'S&P 500', '#D97706'), benchmark_series('QQQ', 'QQQ', '#16A34A')):
|
||||
if item:
|
||||
datasets.append(item)
|
||||
spy = _benchmark('SPY', 'S&P 500', '#D97706')
|
||||
qqq = _benchmark('QQQ', 'QQQ', '#16A34A')
|
||||
if spy:
|
||||
datasets.append(spy)
|
||||
if qqq:
|
||||
datasets.append(qqq)
|
||||
|
||||
labels = [week_label_date[w].strftime('%b %-d') for w in all_week_keys]
|
||||
labels = [d.strftime('%b %-d') for d in all_dates]
|
||||
return json.dumps({'labels': labels, 'datasets': datasets})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Holdings sync (AI / manual)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict:
|
||||
"""Update Stock records. No cost/price tracking required."""
|
||||
"""Update Stock records. No cost/price tracking."""
|
||||
from django.db import transaction as db_transaction
|
||||
|
||||
results = []
|
||||
@@ -689,7 +338,7 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
|
||||
portfolio.stocks.all().delete()
|
||||
|
||||
for item in holdings:
|
||||
stock_code = item['stock_code'].upper()
|
||||
stock_code = item['stock_code']
|
||||
quantity = Decimal(str(item['quantity']))
|
||||
|
||||
stock, created = Stock.objects.update_or_create(
|
||||
@@ -698,7 +347,7 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
|
||||
defaults={'quantity': quantity},
|
||||
)
|
||||
results.append({
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_code': stock_code,
|
||||
'quantity': float(quantity),
|
||||
'created': created,
|
||||
})
|
||||
|
||||
+3
-4
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Invest Dashboard{% endblock %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} Snapshot</p>
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} SNAPSHOT</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Top metric cards ──────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
|
||||
|
||||
<!-- Total value -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Total Value</p>
|
||||
{% if overview.this_week_total is not None %}
|
||||
@@ -21,169 +23,127 @@
|
||||
{% else %}
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
{% endif %}
|
||||
<p class="text-sm text-stone-400 mt-1">Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Net Contributions</p>
|
||||
<p class="text-3xl font-bold text-blue-800">${{ net_contributions|floatformat:0 }}</p>
|
||||
<p class="text-sm text-stone-400 mt-1">External deposits minus withdrawals</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Investment Gain</p>
|
||||
<p class="text-3xl font-bold {% if performance.cash_adjusted_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
|
||||
{% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0 }}
|
||||
<p class="text-sm text-stone-400 mt-1">
|
||||
Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}
|
||||
</p>
|
||||
<p class="text-sm text-stone-400 mt-1">Cash-flow adjusted</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Top 5 Concentration</p>
|
||||
<p class="text-3xl font-bold {% if risk.concentration_level == 'HIGH' %}text-red-700{% elif risk.concentration_level == 'MEDIUM' %}text-amber-700{% else %}text-green-800{% endif %}">
|
||||
{% widthratio risk.top_5_weight 1 100 %}%
|
||||
</p>
|
||||
<p class="text-sm text-stone-400 mt-1">Risk: {{ risk.concentration_level }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-3">
|
||||
<!-- This week's gain -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">This Week</p>
|
||||
{% if overview.week_gain is not None %}
|
||||
<p class="text-2xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
|
||||
<p class="text-3xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
|
||||
{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0 }}
|
||||
</p>
|
||||
<p class="text-sm text-stone-400 mt-1">{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last snapshot</p>
|
||||
<p class="text-sm text-stone-400 mt-1">
|
||||
{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last week
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-2xl font-bold text-stone-400">—</p>
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
<p class="text-sm text-stone-400 mt-1">No prior snapshot</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Week change % -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Money Weighted Return</p>
|
||||
{% if performance.money_weighted_return is not None %}
|
||||
<p class="text-2xl font-bold text-stone-900">{% widthratio performance.money_weighted_return 1 100 %}%</p>
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Week Change</p>
|
||||
{% if overview.week_change_pct is not None %}
|
||||
<p class="text-3xl font-bold {% if overview.week_change_pct >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
|
||||
{% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}%
|
||||
</p>
|
||||
{% if overview.last_week_date %}
|
||||
<p class="text-sm text-stone-400 mt-1">Last: {{ overview.last_week_date|date:"M j" }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-2xl font-bold text-stone-400">—</p>
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
<p class="text-sm text-stone-400 mt-1">Need 2+ snapshots</p>
|
||||
{% endif %}
|
||||
<p class="text-sm text-stone-400 mt-1">IRR based on cash flows</p>
|
||||
</div>
|
||||
|
||||
<!-- Last snapshot date -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Last Snapshot</p>
|
||||
{% if overview.this_week_date %}
|
||||
<p class="text-2xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
|
||||
<p class="text-3xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
|
||||
<p class="text-sm text-stone-400 mt-1">{{ overview.this_week_date|date:"l, Y" }}</p>
|
||||
{% else %}
|
||||
<p class="text-2xl font-bold text-stone-400">—</p>
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
<p class="text-sm text-stone-400 mt-1">No snapshots yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Same-cashflow benchmark ───────────────────────────────── -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Same-cashflow Benchmark</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p class="text-sm text-stone-400">Actual end value</p>
|
||||
<p class="text-xl font-bold text-stone-900">${{ performance.end_value|floatformat:0 }}</p>
|
||||
</div>
|
||||
{% for ticker, bench in performance.benchmarks.items %}
|
||||
<div>
|
||||
<p class="text-sm text-stone-400">Same cash flows into {{ ticker }}</p>
|
||||
<p class="text-xl font-bold text-stone-900">${{ bench.end_value|floatformat:0 }}</p>
|
||||
<p class="text-xs text-stone-400">Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}</p>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-sm text-stone-400">Benchmark prices unavailable. The API still returns portfolio metrics.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Performance chart ─────────────────────────────────────── -->
|
||||
{% if chart_data_json != 'null' %}
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">{{ fy_label }} Performance vs Benchmarks</p>
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">
|
||||
{{ fy_label }} Performance vs Benchmarks
|
||||
</p>
|
||||
<canvas id="performanceChart" height="90"></canvas>
|
||||
<p class="text-xs text-stone-400 mt-3">Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Risk panel ────────────────────────────────────────────── -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mb-4">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Risk Overview</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div><p class="text-xs text-stone-400">Top 1</p><p class="font-bold">{% widthratio risk.top_1_weight 1 100 %}%</p></div>
|
||||
<div><p class="text-xs text-stone-400">Top 3</p><p class="font-bold">{% widthratio risk.top_3_weight 1 100 %}%</p></div>
|
||||
<div><p class="text-xs text-stone-400">Semiconductors</p><p class="font-bold">{% widthratio risk.theme_exposure.semiconductors 1 100 %}%</p></div>
|
||||
<div><p class="text-xs text-stone-400">AI / Cloud</p><p class="font-bold">{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%</p></div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
|
||||
<th class="py-2 text-left">Ticker</th>
|
||||
<th class="py-2 text-right">Value</th>
|
||||
<th class="py-2 text-right">Weight</th>
|
||||
<th class="py-2 text-left">Portfolio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for position in risk.top_positions|slice:":5" %}
|
||||
<tr class="border-b border-stone-50">
|
||||
<td class="py-2 font-medium">{{ position.stock_code }}</td>
|
||||
<td class="py-2 text-right">${{ position.current_value|floatformat:0 }}</td>
|
||||
<td class="py-2 text-right">{% widthratio position.weight 1 100 %}%</td>
|
||||
<td class="py-2 text-stone-500">{{ position.portfolio_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Hint ──────────────────────────────────────────────────── -->
|
||||
<p class="text-xs text-stone-400 text-center mt-2 mb-6">
|
||||
Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-stone-400 text-center mt-2 mb-6">Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync</p>
|
||||
|
||||
<!-- ── Live holdings ─────────────────────────────────────────── -->
|
||||
<!-- ── Live holdings (per-portfolio cards) ───────────────────── -->
|
||||
{% if all_holdings %}
|
||||
<div class="space-y-4">
|
||||
{% for group in all_holdings %}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<!-- Card header -->
|
||||
<div class="px-6 py-4 flex items-center justify-between border-b border-stone-100">
|
||||
<div>
|
||||
<p class="font-semibold text-stone-900 text-sm">{{ group.portfolio.name }}</p>
|
||||
<p class="text-xs text-stone-400 mt-0.5">{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}</p>
|
||||
<p class="font-semibold text-stone-900 text-sm">
|
||||
{{ group.portfolio.name }}
|
||||
</p>
|
||||
<p class="text-xs text-stone-400 mt-0.5">
|
||||
{{ group.position_count }} position{{ group.position_count|pluralize }}
|
||||
{% if overview.this_week_date %}· Last updated {{ overview.this_week_date|date:"j M Y" }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-stone-900 text-sm">{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300">—</span>{% endif %}</p>
|
||||
<p class="font-bold text-stone-900 text-sm">
|
||||
{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</p>
|
||||
{% if group.change is not None %}
|
||||
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)</p>
|
||||
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">
|
||||
{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }}
|
||||
({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-stone-300 mt-0.5">No prior snapshot</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Holdings table -->
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
|
||||
<th class="px-6 py-3 text-left">Ticker</th>
|
||||
<th class="px-6 py-3 text-right">Qty</th>
|
||||
<th class="px-6 py-3 text-right">Price</th>
|
||||
<th class="px-6 py-3 text-right">Week Change</th>
|
||||
<th class="px-6 py-3 text-right">Mkt Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stock in group.holdings %}
|
||||
<tr class="border-b border-stone-50 hover:bg-stone-50 {{ group.colors.row }}">
|
||||
<td class="px-6 py-3 font-medium text-stone-900"><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">{{ stock.stock_code }}</span></td>
|
||||
<td class="px-6 py-3 font-medium text-stone-900">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">
|
||||
{{ stock.stock_code }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:0 }}</td>
|
||||
<td class="px-6 py-3 text-right text-stone-500">{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300">—</span>{% endif %}</td>
|
||||
<td class="px-6 py-3 text-right {% if stock.value_change >= 0 %}text-green-700{% elif stock.value_change < 0 %}text-red-600{% else %}text-stone-300{% endif %}">{% if stock.value_change is not None %}{% if stock.value_change >= 0 %}+{% endif %}${{ stock.value_change|floatformat:0 }} ({% if stock.price_change_pct >= 0 %}+{% endif %}{{ stock.price_change_pct|floatformat:1 }}%){% else %}—{% endif %}</td>
|
||||
<td class="px-6 py-3 text-right font-semibold text-stone-800">{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300">—</span>{% endif %}</td>
|
||||
<td class="px-6 py-3 text-right text-stone-500">
|
||||
{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right font-semibold text-stone-800">
|
||||
{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -193,41 +153,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Transactions ──────────────────────────────────────────── -->
|
||||
{% if recent_transactions %}
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mt-4">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Transaction History</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
|
||||
<th class="py-2 text-left">Date</th>
|
||||
<th class="py-2 text-left">Portfolio</th>
|
||||
<th class="py-2 text-left">Action</th>
|
||||
<th class="py-2 text-left">Ticker</th>
|
||||
<th class="py-2 text-right">Qty</th>
|
||||
<th class="py-2 text-right">Price</th>
|
||||
<th class="py-2 text-right">Fee</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in recent_transactions %}
|
||||
<tr class="border-b border-stone-50">
|
||||
<td class="py-2">{{ tx.date|date:"j M Y" }}</td>
|
||||
<td class="py-2">{{ tx.portfolio.name }}</td>
|
||||
<td class="py-2">{{ tx.action }}</td>
|
||||
<td class="py-2 font-medium">{{ tx.stock_code }}</td>
|
||||
<td class="py-2 text-right">{{ tx.quantity|floatformat:0 }}</td>
|
||||
<td class="py-2 text-right">{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2 }}{% else %}<span class="text-stone-300">optional</span>{% endif %}</td>
|
||||
<td class="py-2 text-right">{% if tx.fee %}${{ tx.fee|floatformat:2 }}{% else %}<span class="text-stone-300">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -236,8 +161,10 @@
|
||||
(function () {
|
||||
const raw = {{ chart_data_json|safe }};
|
||||
if (!raw) return;
|
||||
|
||||
const ctx = document.getElementById('performanceChart');
|
||||
if (!ctx) return;
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: raw,
|
||||
@@ -245,12 +172,42 @@
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top', align: 'start', labels: { usePointStyle: true, pointStyle: 'rect', pointStyleWidth: 14, padding: 20, font: { size: 12, weight: '600' } } },
|
||||
tooltip: { callbacks: { label: function (ctx) { const v = ctx.parsed.y; if (v === null || v === undefined) return ctx.dataset.label + ': —'; const sign = v >= 0 ? '+' : ''; return ctx.dataset.label + ': ' + sign + v.toFixed(2) + '%'; } } },
|
||||
legend: {
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'rect',
|
||||
pointStyleWidth: 14,
|
||||
padding: 20,
|
||||
font: { size: 12, weight: '600' },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
const v = ctx.parsed.y;
|
||||
if (v === null || v === undefined) return ctx.dataset.label + ': —';
|
||||
const sign = v >= 0 ? '+' : '';
|
||||
return ctx.dataset.label + ': ' + sign + v.toFixed(2) + '%';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: { ticks: { callback: function (v) { return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }, font: { size: 11 } }, grid: { color: '#f5f5f4' } },
|
||||
x: { grid: { display: false }, ticks: { font: { size: 11 } } },
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function (v) {
|
||||
return (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
|
||||
},
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: { color: '#f5f5f4' },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+28
-85
@@ -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())
|
||||
|
||||
@@ -28,7 +28,6 @@ dependencies = [
|
||||
"cryptography>=42.0.0",
|
||||
"kubernetes>=29.0.0",
|
||||
"maxminddb>=3.1.1",
|
||||
"yfinance>=0.2.66",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user