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.
197 lines
7.1 KiB
Python
197 lines
7.1 KiB
Python
import logging
|
|
from decimal import Decimal
|
|
|
|
from django.shortcuts import get_object_or_404
|
|
from django.utils import timezone
|
|
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 Portfolio, Stock, Transaction, Report, PriceCache
|
|
from .serializers import (
|
|
PortfolioSerializer, PortfolioListSerializer,
|
|
StockSerializer, StockListSerializer,
|
|
TransactionSerializer, ReportSerializer,
|
|
AIBatchUpdateSerializer,
|
|
)
|
|
from .services import (
|
|
portfolio_summary, ai_batch_update, get_quotes,
|
|
create_portfolio, upsert_stock, add_transaction, delete_transaction,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PortfolioViewSet(viewsets.ModelViewSet):
|
|
queryset = Portfolio.objects.all()
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == 'list':
|
|
return PortfolioListSerializer
|
|
return PortfolioSerializer
|
|
|
|
def perform_create(self, serializer):
|
|
serializer.save()
|
|
|
|
@action(detail=True, methods=['get'], url_path='summary')
|
|
def summary(self, request, pk=None):
|
|
"""Return full portfolio summary with current prices and P&L."""
|
|
portfolio = self.get_object()
|
|
try:
|
|
data = portfolio_summary(portfolio)
|
|
return Response(data)
|
|
except Exception as exc:
|
|
logger.error("portfolio_summary failed for %s: %s", portfolio.id, exc, exc_info=True)
|
|
return Response({'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
@action(detail=True, methods=['get'], url_path='transactions')
|
|
def transactions(self, request, pk=None):
|
|
"""List all transactions for all stocks in this portfolio."""
|
|
portfolio = self.get_object()
|
|
txs = Transaction.objects.filter(
|
|
stock__portfolio=portfolio
|
|
).select_related('stock').order_by('-date', '-created_at')
|
|
serializer = TransactionSerializer(txs, many=True)
|
|
return Response(serializer.data)
|
|
|
|
|
|
class StockViewSet(viewsets.ModelViewSet):
|
|
queryset = Stock.objects.select_related('portfolio').all()
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == 'list':
|
|
return StockListSerializer
|
|
return StockSerializer
|
|
|
|
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)
|
|
active_only = self.request.query_params.get('active')
|
|
if active_only and active_only.lower() in ('true', '1'):
|
|
qs = qs.filter(is_active=True)
|
|
return qs
|
|
|
|
@action(detail=True, methods=['get'], url_path='transactions')
|
|
def transactions(self, request, pk=None):
|
|
stock = self.get_object()
|
|
txs = stock.transactions.all().order_by('-date', '-created_at')
|
|
serializer = TransactionSerializer(txs, many=True)
|
|
return Response(serializer.data)
|
|
|
|
|
|
class TransactionViewSet(viewsets.ModelViewSet):
|
|
queryset = Transaction.objects.select_related('stock', 'stock__portfolio').all()
|
|
serializer_class = TransactionSerializer
|
|
|
|
def get_queryset(self):
|
|
qs = super().get_queryset()
|
|
stock_id = self.request.query_params.get('stock')
|
|
if stock_id:
|
|
qs = qs.filter(stock_id=stock_id)
|
|
portfolio_id = self.request.query_params.get('portfolio')
|
|
if portfolio_id:
|
|
qs = qs.filter(stock__portfolio_id=portfolio_id)
|
|
return qs.order_by('-date', '-created_at')
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
data = serializer.validated_data
|
|
stock = data['stock']
|
|
try:
|
|
tx = add_transaction(
|
|
stock=stock,
|
|
tx_type=data['tx_type'],
|
|
date=data['date'],
|
|
price_per_share=data['price_per_share'],
|
|
shares=data['shares'],
|
|
fee=data.get('fee', Decimal('0')),
|
|
notes=data.get('notes', ''),
|
|
source=data.get('source', 'manual'),
|
|
idempotency_key=data.get('idempotency_key'),
|
|
)
|
|
except Exception as exc:
|
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
out = TransactionSerializer(tx)
|
|
return Response(out.data, status=status.HTTP_201_CREATED)
|
|
|
|
def destroy(self, request, *args, **kwargs):
|
|
tx = self.get_object()
|
|
try:
|
|
delete_transaction(tx)
|
|
except Exception as exc:
|
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
class ReportViewSet(viewsets.ModelViewSet):
|
|
queryset = Report.objects.select_related('portfolio').all()
|
|
serializer_class = ReportSerializer
|
|
|
|
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
|
|
|
|
|
|
class QuotesView(APIView):
|
|
"""
|
|
POST /api/invest/quotes/
|
|
Body: {"tickers": [{"ticker": "AAPL", "exchange": ""}, ...], "force_refresh": false}
|
|
"""
|
|
|
|
def post(self, request):
|
|
tickers_data = request.data.get('tickers', [])
|
|
force_refresh = request.data.get('force_refresh', False)
|
|
|
|
if not isinstance(tickers_data, list) or not tickers_data:
|
|
return Response(
|
|
{'error': 'tickers must be a non-empty list'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
pairs = [(item.get('ticker', ''), item.get('exchange', '')) for item in tickers_data]
|
|
try:
|
|
quotes = get_quotes(pairs, force_refresh=force_refresh)
|
|
return Response(quotes)
|
|
except Exception as exc:
|
|
logger.error("get_quotes failed: %s", exc, exc_info=True)
|
|
return Response({'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
class AIUpdateView(APIView):
|
|
"""
|
|
POST /api/invest/ai-update/
|
|
Accepts a JSON body with a list of operations and applies them atomically.
|
|
Supports dry_run mode.
|
|
"""
|
|
|
|
def post(self, request):
|
|
serializer = AIBatchUpdateSerializer(data=request.data)
|
|
if not serializer.is_valid():
|
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
operations = serializer.validated_data['operations']
|
|
dry_run = serializer.validated_data['dry_run']
|
|
|
|
try:
|
|
results = ai_batch_update(operations, dry_run=dry_run)
|
|
except Exception as exc:
|
|
logger.error("ai_batch_update failed: %s", exc, exc_info=True)
|
|
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
has_errors = any(r.get('status') == 'error' for r in results)
|
|
http_status = status.HTTP_200_OK if not has_errors else status.HTTP_207_MULTI_STATUS
|
|
return Response({
|
|
'dry_run': dry_run,
|
|
'results': results,
|
|
'applied': not dry_run and not has_errors,
|
|
}, status=http_status)
|