mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
P1: - WAC cost basis per (portfolio, ticker): avg_cost / unrealized P&L / P&L% - transaction price when recorded, else trade-date market close (estimated, flagged) - dashboard: Unrealized P&L card + Avg Cost & P&L columns on holdings tables - fx: frankfurter.dev rates (USD/HKD->AUD, 24h cache, UA header required) -> AUD total on Total Value card - chart: 1M/3M/6M/YTD/1Y/ALL period buttons (all periods precomputed server-side, top-level payload stays YTD for compat) P2: - /invest/stocks/<ticker>/ per-ticker page: cross-account holdings, WAC, P&L, weight, theme, trade history - manual transaction + cashflow entry forms (source=MANUAL, confidence=1.0) - dashboard transaction search (?q= ticker/account) + add-transaction buttons - tickers link to detail page; tabular-nums on all figures - 8 new tests (WAC sell, estimated price, incomplete, per-portfolio agg, stock page, forms, search)
225 lines
8.2 KiB
Python
225 lines
8.2 KiB
Python
"""Template views for the invest app."""
|
||
import logging
|
||
from decimal import Decimal
|
||
|
||
from django.db.models import Q
|
||
from django.shortcuts import get_object_or_404, redirect, render
|
||
from django.utils import timezone
|
||
|
||
from links.models import Post, Tag
|
||
|
||
from .forms import CashFlowForm, TransactionForm
|
||
from .models import Portfolio, Transaction
|
||
from .services import (
|
||
SEMI_TICKERS,
|
||
AI_CLOUD_TICKERS,
|
||
get_all_holdings,
|
||
get_aud_total,
|
||
get_cashflow_adjusted_performance,
|
||
get_cost_basis,
|
||
get_current_price,
|
||
get_net_external_cash_flow,
|
||
get_performance_chart_data,
|
||
get_portfolio_value,
|
||
get_risk_summary,
|
||
get_weekly_overview,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def dashboard(request):
|
||
"""Landing page: agent-first metrics + human-readable holdings/risk dashboard."""
|
||
overview = get_weekly_overview()
|
||
|
||
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)
|
||
rows_by_id = {row['portfolio'].id: row for row in overview.get('portfolio_rows', [])}
|
||
cost_basis = get_cost_basis()
|
||
for group in all_holdings:
|
||
row = rows_by_id.get(group['portfolio'].id, {})
|
||
group['this_week_value'] = row.get('this_week_value')
|
||
group['change'] = row.get('change')
|
||
group['change_pct'] = row.get('change_pct')
|
||
group['position_count'] = row.get('position_count', len(group['holdings']))
|
||
# 注入成本数据(模板无法用动态 dict key 查 cost_basis)
|
||
cb = cost_basis['by_portfolio'].get(group['portfolio'].id, {})
|
||
for holding in group['holdings']:
|
||
holding['cost'] = cb.get(holding['stock_code'])
|
||
|
||
performance = get_cashflow_adjusted_performance()
|
||
risk = get_risk_summary()
|
||
|
||
# 全局未实现盈亏汇总
|
||
pnl_rows = [r for r in cost_basis['flattened'] if r['unrealized_pnl'] is not None]
|
||
total_cost = sum(r['total_cost'] for r in pnl_rows)
|
||
total_pnl = sum(r['unrealized_pnl'] for r in pnl_rows)
|
||
total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else None
|
||
|
||
# 交易搜索
|
||
search_q = request.GET.get('q', '').strip()
|
||
tx_qs = Transaction.objects.select_related('portfolio').order_by('-date', '-created_at')
|
||
if search_q:
|
||
tx_qs = tx_qs.filter(
|
||
Q(stock_code__icontains=search_q) | Q(portfolio__name__icontains=search_q)
|
||
)
|
||
recent_transactions = tx_qs[:30]
|
||
|
||
invest_tags = list(Tag.objects.filter(slug__in=['invest', 'investment']).order_by('slug'))
|
||
invest_tag = next((tag for tag in invest_tags if tag.slug == 'invest'), None) or (invest_tags[0] if invest_tags else None)
|
||
investment_posts = Post.objects.none()
|
||
if invest_tags:
|
||
investment_posts = (
|
||
Post.objects.filter(tags__in=invest_tags)
|
||
.distinct()
|
||
.order_by('-created_at')[:12]
|
||
)
|
||
|
||
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,
|
||
'cost_basis': cost_basis,
|
||
'total_pnl': round(total_pnl, 2),
|
||
'total_pnl_pct': round(total_pnl_pct, 6) if total_pnl_pct is not None else None,
|
||
'aud_total': get_aud_total(),
|
||
'recent_transactions': recent_transactions,
|
||
'search_q': search_q,
|
||
'invest_tag': invest_tag,
|
||
'investment_posts': investment_posts,
|
||
})
|
||
|
||
|
||
def portfolio_detail(request, pk):
|
||
"""Portfolio detail: live holdings."""
|
||
portfolio = get_object_or_404(Portfolio, pk=pk)
|
||
try:
|
||
summary = get_portfolio_value(portfolio)
|
||
except Exception as exc:
|
||
logger.error("get_portfolio_value failed for %s: %s", pk, exc)
|
||
summary = {
|
||
'portfolio_id': portfolio.id,
|
||
'portfolio_name': portfolio.name,
|
||
'holdings': [],
|
||
'total_value': 0,
|
||
}
|
||
cost_basis = get_cost_basis()
|
||
cb = cost_basis['by_portfolio'].get(portfolio.id, {})
|
||
for holding in summary.get('holdings', []):
|
||
holding['cost'] = cb.get(holding['stock_code'])
|
||
return render(request, 'invest/portfolio_detail.html', {
|
||
'portfolio': portfolio,
|
||
'summary': summary,
|
||
'cost_basis': cost_basis,
|
||
})
|
||
|
||
def portfolio_transactions(request, pk):
|
||
"""Transaction history for a portfolio."""
|
||
portfolio = get_object_or_404(Portfolio, pk=pk)
|
||
transactions = portfolio.transactions.all().order_by('-date', '-created_at')
|
||
return render(request, 'invest/transactions.html', {
|
||
'portfolio': portfolio,
|
||
'transactions': transactions,
|
||
})
|
||
|
||
|
||
def stock_detail(request, ticker):
|
||
"""Per-ticker view: cross-account holdings, cost basis, P&L, trade history."""
|
||
ticker = ticker.upper()
|
||
portfolios = list(Portfolio.objects.all())
|
||
current_price = get_current_price(ticker)
|
||
|
||
positions = []
|
||
total_qty = Decimal('0')
|
||
for portfolio in portfolios:
|
||
for stock in portfolio.stocks.filter(stock_code=ticker, quantity__gt=0):
|
||
value = float(current_price or 0) * float(stock.quantity)
|
||
total_qty += stock.quantity
|
||
positions.append({
|
||
'portfolio': portfolio,
|
||
'quantity': float(stock.quantity),
|
||
'current_value': round(value, 2),
|
||
'current_price': current_price,
|
||
})
|
||
|
||
# 全局成本(跨账户合并)
|
||
cb_rows = [r for r in get_cost_basis()['flattened'] if r['stock_code'] == ticker]
|
||
total_cost = sum(r['total_cost'] for r in cb_rows)
|
||
total_value = sum(r['current_value'] for r in cb_rows)
|
||
total_pnl = sum(r['unrealized_pnl'] for r in cb_rows if r['unrealized_pnl'] is not None)
|
||
avg_cost = (total_cost / float(total_qty)) if total_qty > 0 else None
|
||
pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else None
|
||
estimated = any(r['estimated'] for r in cb_rows)
|
||
|
||
# 权重 & 主题
|
||
risk = get_risk_summary()
|
||
weight = next((r['weight'] for r in risk['top_positions'] if r['stock_code'] == ticker), None)
|
||
if ticker in SEMI_TICKERS:
|
||
theme = '半导体'
|
||
elif ticker in AI_CLOUD_TICKERS:
|
||
theme = 'AI / 云计算'
|
||
else:
|
||
theme = '—'
|
||
|
||
transactions = (
|
||
Transaction.objects.filter(stock_code=ticker)
|
||
.select_related('portfolio')
|
||
.order_by('-date', '-created_at')
|
||
)
|
||
|
||
return render(request, 'invest/stock_detail.html', {
|
||
'ticker': ticker,
|
||
'current_price': current_price,
|
||
'positions': positions,
|
||
'total_qty': float(total_qty),
|
||
'total_cost': round(total_cost, 2),
|
||
'total_value': round(total_value, 2),
|
||
'total_pnl': round(total_pnl, 2),
|
||
'avg_cost': avg_cost,
|
||
'pnl_pct': pnl_pct,
|
||
'estimated': estimated,
|
||
'weight': weight,
|
||
'theme': theme,
|
||
'transactions': transactions,
|
||
'portfolio_count': len([p for p in portfolios if p.stocks.filter(stock_code=ticker, quantity__gt=0).exists()]),
|
||
})
|
||
|
||
|
||
def transaction_create(request):
|
||
"""Manual transaction entry form."""
|
||
if request.method == 'POST':
|
||
form = TransactionForm(request.POST)
|
||
if form.is_valid():
|
||
tx = form.save(commit=False)
|
||
tx.stock_code = tx.stock_code.upper()
|
||
tx.source = 'MANUAL'
|
||
tx.confidence = Decimal('1.0')
|
||
tx.save()
|
||
return redirect('invest-dashboard')
|
||
else:
|
||
form = TransactionForm()
|
||
return render(request, 'invest/transaction_form.html', {'form': form, 'form_title': '添加交易记录'})
|
||
|
||
|
||
def cashflow_create(request):
|
||
"""Manual cash-flow entry form."""
|
||
if request.method == 'POST':
|
||
form = CashFlowForm(request.POST)
|
||
if form.is_valid():
|
||
flow = form.save(commit=False)
|
||
flow.source = 'MANUAL'
|
||
flow.confidence = Decimal('1.0')
|
||
flow.save()
|
||
return redirect('invest-dashboard')
|
||
else:
|
||
form = CashFlowForm()
|
||
return render(request, 'invest/cashflow_form.html', {'form': form, 'form_title': '添加现金流记录'})
|