mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
208 lines
6.7 KiB
Python
208 lines
6.7 KiB
Python
from decimal import Decimal
|
|
|
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
from django.db import models
|
|
|
|
|
|
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)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
ordering = ['name']
|
|
|
|
|
|
class Stock(models.Model):
|
|
"""Current holdings for a portfolio. Quantity only — prices are fetched on demand."""
|
|
|
|
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(
|
|
max_digits=20,
|
|
decimal_places=6,
|
|
default=Decimal('0'),
|
|
validators=[MinValueValidator(Decimal('0'))],
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = [('portfolio', 'stock_code')]
|
|
ordering = ['stock_code']
|
|
|
|
def __str__(self):
|
|
return f"{self.stock_code} ({self.portfolio.name})"
|
|
|
|
|
|
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.
|
|
"""
|
|
|
|
ACTION_BUY = 'BUY'
|
|
ACTION_SELL = 'SELL'
|
|
ACTION_CHOICES = [(ACTION_BUY, 'Buy'), (ACTION_SELL, 'Sell')]
|
|
|
|
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='transactions')
|
|
action = models.CharField(max_length=4, choices=ACTION_CHOICES)
|
|
stock_code = models.CharField(max_length=20)
|
|
quantity = models.DecimalField(
|
|
max_digits=20,
|
|
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."""
|
|
|
|
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='snapshots')
|
|
captured_at = models.DateTimeField()
|
|
total_value = models.DecimalField(max_digits=20, decimal_places=2)
|
|
|
|
class Meta:
|
|
ordering = ['-captured_at']
|
|
indexes = [
|
|
models.Index(fields=['portfolio', 'captured_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.portfolio.name} @ {self.captured_at:%Y-%m-%d %H:%M}: ${self.total_value}"
|
|
|
|
|
|
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}"
|