mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
- Portfolio management (MOMO, IBKR personal, IBKR Yanhua) - Stock holdings with transaction history (buy/sell) - Weekly AI report generation - Real-time price cache via Finnhub API - Dashboard with tree view and performance charts - REST API with AI-friendly batch update endpoint - Management command for scheduled report generation For大哥's personal investment advisor system.
143 lines
5.0 KiB
Python
143 lines
5.0 KiB
Python
from django.db import models
|
||
from django.core.validators import MinValueValidator
|
||
from decimal import Decimal
|
||
|
||
|
||
class Portfolio(models.Model):
|
||
name = models.CharField(max_length=100)
|
||
description = models.TextField(blank=True)
|
||
account_id = models.CharField(max_length=50, blank=True)
|
||
base_currency = models.CharField(max_length=10, default='USD')
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
class Meta:
|
||
ordering = ['name']
|
||
|
||
|
||
class Stock(models.Model):
|
||
"""Represents a stock holding within a portfolio."""
|
||
|
||
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='stocks')
|
||
ticker = models.CharField(max_length=20)
|
||
exchange = models.CharField(
|
||
max_length=10, blank=True, default='',
|
||
help_text="Exchange code, e.g. NASDAQ, HKG. Empty = US market default."
|
||
)
|
||
company_name = models.CharField(max_length=200, blank=True)
|
||
shares_held = models.DecimalField(
|
||
max_digits=20, decimal_places=6, default=Decimal('0'),
|
||
validators=[MinValueValidator(Decimal('0'))]
|
||
)
|
||
avg_cost = models.DecimalField(
|
||
max_digits=20, decimal_places=6, default=Decimal('0'),
|
||
validators=[MinValueValidator(Decimal('0'))],
|
||
help_text="Weighted average cost per share in quote_currency"
|
||
)
|
||
quote_currency = models.CharField(max_length=10, default='USD')
|
||
is_active = models.BooleanField(default=True)
|
||
notes = models.TextField(blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.ticker} ({self.portfolio.name})"
|
||
|
||
@property
|
||
def finnhub_symbol(self):
|
||
"""Return the symbol in the format Finnhub expects."""
|
||
if self.exchange.upper() == 'HKG':
|
||
return f"{self.ticker}.HK"
|
||
return self.ticker
|
||
|
||
class Meta:
|
||
unique_together = [('portfolio', 'ticker', 'exchange')]
|
||
ordering = ['ticker']
|
||
|
||
|
||
class Transaction(models.Model):
|
||
TX_BUY = 'BUY'
|
||
TX_SELL = 'SELL'
|
||
TX_TYPES = [(TX_BUY, 'Buy'), (TX_SELL, 'Sell')]
|
||
|
||
stock = models.ForeignKey(Stock, on_delete=models.CASCADE, related_name='transactions')
|
||
tx_type = models.CharField(max_length=4, choices=TX_TYPES)
|
||
date = models.DateField()
|
||
price_per_share = models.DecimalField(
|
||
max_digits=20, decimal_places=6,
|
||
validators=[MinValueValidator(Decimal('0'))]
|
||
)
|
||
shares = models.DecimalField(
|
||
max_digits=20, decimal_places=6,
|
||
validators=[MinValueValidator(Decimal('0.000001'))]
|
||
)
|
||
fee = models.DecimalField(
|
||
max_digits=20, decimal_places=6, default=Decimal('0'),
|
||
validators=[MinValueValidator(Decimal('0'))]
|
||
)
|
||
notes = models.TextField(blank=True)
|
||
source = models.CharField(
|
||
max_length=20, default='manual',
|
||
help_text="Origin of transaction: 'manual', 'ai', 'import'"
|
||
)
|
||
idempotency_key = models.CharField(
|
||
max_length=100, blank=True, null=True, unique=True,
|
||
help_text="Unique key to prevent duplicate AI writes"
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
def __str__(self):
|
||
return f"{self.tx_type} {self.shares} {self.stock.ticker} @ {self.price_per_share}"
|
||
|
||
class Meta:
|
||
ordering = ['date', 'created_at']
|
||
|
||
|
||
class Report(models.Model):
|
||
REPORT_WEEKLY = 'WEEKLY'
|
||
REPORT_MANUAL = 'MANUAL'
|
||
REPORT_TYPES = [(REPORT_WEEKLY, 'Weekly'), (REPORT_MANUAL, 'Manual')]
|
||
|
||
portfolio = models.ForeignKey(
|
||
Portfolio, on_delete=models.SET_NULL, null=True, blank=True, related_name='reports'
|
||
)
|
||
title = models.CharField(max_length=200)
|
||
content = models.TextField()
|
||
period_start = models.DateField()
|
||
period_end = models.DateField()
|
||
generated_at = models.DateTimeField(auto_now_add=True)
|
||
report_type = models.CharField(max_length=10, choices=REPORT_TYPES, default=REPORT_WEEKLY)
|
||
valuation_snapshot = models.JSONField(
|
||
default=dict,
|
||
help_text="Snapshot of prices and holdings at time of report generation"
|
||
)
|
||
|
||
def __str__(self):
|
||
return f"{self.title} ({self.period_start} – {self.period_end})"
|
||
|
||
class Meta:
|
||
ordering = ['-generated_at']
|
||
|
||
|
||
class PriceCache(models.Model):
|
||
"""Cache for real-time quotes fetched from Finnhub. TTL: 15 minutes."""
|
||
|
||
ticker = models.CharField(max_length=20)
|
||
exchange = models.CharField(max_length=10, blank=True, default='')
|
||
price = models.DecimalField(max_digits=20, decimal_places=6)
|
||
currency = models.CharField(max_length=10, default='USD')
|
||
change_percent = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
||
prev_close = models.DecimalField(max_digits=20, decimal_places=6, null=True, blank=True)
|
||
fetched_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
indexes = [
|
||
models.Index(fields=['ticker', 'exchange', 'fetched_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.ticker}: {self.price} @ {self.fetched_at}"
|