Files
links/invest/template_views.py
T
OpenClaw Sub-agent b67a834def feat(invest): cost basis & P&L, AUD totals, chart ranges, per-ticker page, manual entry, tx search
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)
2026-08-02 08:09:58 +10:00

225 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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': '添加现金流记录'})