Files
links/invest/views.py
T
OpenClaw Sub-agent 8d05a11eb6 feat(invest): Add investment portfolio management feature
- 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.
2026-04-18 11:26:48 +10:00

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)