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)
This commit is contained in:
OpenClaw Sub-agent
2026-08-02 08:09:58 +10:00
parent 911015e2f6
commit b67a834def
9 changed files with 952 additions and 100 deletions
+68
View File
@@ -0,0 +1,68 @@
"""Forms for manual invest data entry (transactions & cash flows)."""
from django import forms
from .models import CashFlow, Portfolio, Transaction
class TransactionForm(forms.ModelForm):
class Meta:
model = Transaction
fields = [
'portfolio', 'action', 'stock_code', 'quantity',
'price_per_share', 'currency', 'fee', 'date',
]
widgets = {
'date': forms.DateInput(attrs={'type': 'date'}),
'stock_code': forms.TextInput(attrs={'placeholder': '如 NVDA / 9988.HK', 'autocomplete': 'off'}),
'quantity': forms.NumberInput(attrs={'step': '0.000001', 'min': '0.000001'}),
'price_per_share': forms.NumberInput(attrs={'step': '0.000001', 'min': '0', 'placeholder': '可空(用当日收盘价估算)'}),
'fee': forms.NumberInput(attrs={'step': '0.01', 'min': '0', 'placeholder': '可空'}),
}
labels = {
'portfolio': '账户',
'action': '类型',
'stock_code': '股票代码',
'quantity': '数量',
'price_per_share': '成交价(可空)',
'currency': '币种',
'fee': '手续费(可空)',
'date': '日期',
}
help_texts = {
'price_per_share': '留空则按交易日收盘价估算成本',
'fee': '以成交币种计',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['portfolio'].queryset = Portfolio.objects.all()
self.fields['portfolio'].empty_label = None
self.fields['price_per_share'].required = False
self.fields['fee'].required = False
self.fields['currency'].initial = 'USD'
class CashFlowForm(forms.ModelForm):
class Meta:
model = CashFlow
fields = ['portfolio', 'flow_type', 'amount', 'currency', 'date', 'note']
widgets = {
'date': forms.DateInput(attrs={'type': 'date'}),
'amount': forms.NumberInput(attrs={'step': '0.01', 'min': '0.01'}),
'note': forms.TextInput(attrs={'placeholder': '备注(可空)'}),
}
labels = {
'portfolio': '账户',
'flow_type': '类型',
'amount': '金额',
'currency': '币种',
'date': '日期',
'note': '备注',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['portfolio'].queryset = Portfolio.objects.all()
self.fields['portfolio'].empty_label = None
self.fields['currency'].initial = 'USD'
self.fields['note'].required = False
+213 -31
View File
@@ -16,7 +16,7 @@ from typing import Iterable, Optional
from django.db.models import Sum
from django.utils import timezone
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
logger = logging.getLogger(__name__)
@@ -27,9 +27,11 @@ logger = logging.getLogger(__name__)
_price_cache: dict[str, tuple[float, datetime]] = {}
_historical_price_cache: dict[str, tuple[Optional[float], datetime]] = {}
_chart_cache: dict = {}
_aud_rates_cache: dict = {}
_PRICE_CACHE_TTL_SECONDS = 300
_HISTORICAL_CACHE_TTL_SECONDS = 3600
_CHART_CACHE_TTL = 900
_AUD_RATES_CACHE_TTL = 86400
SEMI_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM'}
@@ -579,6 +581,142 @@ def get_agent_summary() -> dict:
}
# ---------------------------------------------------------------------------
# Cost basis & unrealized P&L (weighted average cost)
# ---------------------------------------------------------------------------
def get_cost_basis() -> dict:
"""
Weighted-average-cost cost basis per (portfolio, ticker).
Cost price per transaction:
- `price_per_share` when present (manual entry / OCR with price);
- otherwise the market close on the trade date (estimated, via
`_get_historical_price`), so AI/OCR syncs without prices still get a
usable cost basis. Rows with no price at all are flagged `incomplete`.
Returns {portfolio_id: {ticker: {...}}} plus a flattened `by_key` list
friendly for templates: key "portfolio_id:ticker".
"""
txs = Transaction.objects.select_related('portfolio').order_by('date', 'created_at')
acc: dict[tuple[int, str], dict] = {}
for tx in txs:
ticker = tx.stock_code.upper()
key = (tx.portfolio_id, ticker)
a = acc.setdefault(key, {
'quantity': Decimal('0'),
'cost': Decimal('0'),
'estimated': False,
'incomplete': False,
})
price = tx.price_per_share
if price is None:
est = _get_historical_price(ticker, tx.date)
if est is None:
a['incomplete'] = True
continue
price = Decimal(str(round(est, 6)))
a['estimated'] = True
qty = tx.quantity
if tx.action == 'BUY':
a['quantity'] += qty
a['cost'] += qty * price
else: # SELL: reduce at current WAC
wac = a['cost'] / a['quantity'] if a['quantity'] > 0 else price
sell_qty = min(qty, a['quantity'])
a['cost'] -= sell_qty * wac
a['quantity'] -= sell_qty
result: dict[int, dict[str, dict]] = {}
flattened = []
for (portfolio_id, ticker), a in acc.items():
quantity = a['quantity']
if quantity <= 0:
continue
avg_cost = (a['cost'] / quantity) if quantity > 0 else None
current_price = get_current_price(ticker)
current_value = float(Decimal(str(current_price or 0)) * quantity) if current_price else 0.0
total_cost = float(a['cost'])
unrealized_pnl = current_value - total_cost if current_price else None
pnl_pct = (unrealized_pnl / total_cost) if (unrealized_pnl is not None and total_cost > 0) else None
row = {
'stock_code': ticker,
'quantity': float(quantity),
'avg_cost': float(avg_cost) if avg_cost is not None else None,
'total_cost': round(total_cost, 2),
'current_value': round(current_value, 2),
'unrealized_pnl': round(unrealized_pnl, 2) if unrealized_pnl is not None else None,
# 百分数(×100),模板直接 floatformat + %
'pnl_pct': round(pnl_pct * 100, 2) if pnl_pct is not None else None,
'estimated': a['estimated'],
'incomplete': a['incomplete'],
}
result.setdefault(portfolio_id, {})[ticker] = row
flattened.append(row)
return {'by_portfolio': result, 'flattened': flattened}
def get_aud_rates() -> dict:
"""USD->AUD and HKD->AUD spot rates via frankfurter (ECB daily fix). Cached 24h."""
now = datetime.now()
cached = _aud_rates_cache.get('rates')
if cached and (now - cached[1]).total_seconds() < _AUD_RATES_CACHE_TTL:
return cached[0]
rates: dict[str, Optional[float]] = {'USD': None, 'HKD': None}
for ccy in ('USD', 'HKD'):
for base_url in ('https://api.frankfurter.dev/v1/latest', 'https://api.frankfurter.app/latest'):
try:
import json as json_mod
import urllib.request
req = urllib.request.Request(
f'{base_url}?from={ccy}&to=AUD',
headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) links-invest/1.0'},
)
with urllib.request.urlopen(req, timeout=6) as resp:
data = json_mod.loads(resp.read())
rate = float(data['rates']['AUD'])
if rate:
rates[ccy] = rate
break
except Exception as exc:
logger.warning('fx rate failed for %s via %s: %s', ccy, base_url, exc)
_aud_rates_cache['rates'] = (rates, now)
return rates
def get_aud_total() -> Optional[dict]:
"""
Total portfolio value converted to AUD, plus per-currency breakdown.
Currency per holding is inferred from its transactions (default USD).
"""
rates = get_aud_rates()
currency_of: dict[str, str] = {}
for tx in Transaction.objects.all():
currency_of.setdefault(tx.stock_code.upper(), tx.currency or 'USD')
total_aud = Decimal('0')
breakdown: dict[str, dict] = {}
for group in get_all_holdings():
for holding in group['holdings']:
ccy = currency_of.get(holding['stock_code'], 'USD')
rate = rates.get(ccy)
if not rate:
continue
aud_value = Decimal(str(holding['current_value'])) * Decimal(str(rate))
total_aud += aud_value
b = breakdown.setdefault(ccy, {'value': Decimal('0'), 'rate': rate})
b['value'] += Decimal(str(holding['current_value']))
return {
'total_aud': round(float(total_aud), 2),
'rates': rates,
'breakdown': {ccy: {'value': round(float(b['value']), 2), 'rate': b['rate']} for ccy, b in breakdown.items()},
}
# ---------------------------------------------------------------------------
# Performance chart data (snapshot value % vs benchmarks)
# ---------------------------------------------------------------------------
@@ -626,32 +764,31 @@ def _unique_dates(values: Iterable[date_cls]) -> list[date_cls]:
return result
def _build_performance_chart_data() -> Optional[str]:
all_snaps = list(PortfolioSnapshot.objects.select_related('portfolio').order_by('captured_at'))
if not all_snaps:
return None
PERIOD_LABELS = ('1M', '3M', '6M', 'YTD', '1Y', 'ALL')
_PERIOD_DAYS = {'1M': 30, '3M': 91, '6M': 182, '1Y': 365}
portfolio_weekly: dict[int, dict[tuple[int, int], tuple[date_cls, float]]] = {}
week_label_date = {}
for snap in all_snaps:
day = _as_date(snap.captured_at)
key = day.isocalendar()[:2]
portfolio_weekly.setdefault(snap.portfolio_id, {})
existing = portfolio_weekly[snap.portfolio_id].get(key)
if existing is None or day > existing[0]:
portfolio_weekly[snap.portfolio_id][key] = (day, float(snap.total_value))
if key not in week_label_date or day > week_label_date[key]:
week_label_date[key] = day
if not week_label_date:
return None
def _baseline_for_period(period: str, latest_date: date_cls, earliest_date: date_cls) -> date_cls:
if period == 'YTD':
return date_cls(latest_date.year, 1, 1)
if period == 'ALL':
return earliest_date
days = _PERIOD_DAYS.get(period, 30)
return max(latest_date - timedelta(days=days), earliest_date)
earliest_snapshot_date = min(week_label_date.values())
latest_date = max(week_label_date.values())
requested_baseline = date_cls(latest_date.year, 1, 1)
def _build_chart_period(
period: str,
week_label_date: dict,
portfolio_weekly: dict,
earliest_date: date_cls,
latest_date: date_cls,
colors: list[str],
) -> Optional[dict]:
requested_baseline = _baseline_for_period(period, latest_date, earliest_date)
baseline_total = get_total_value_asof(requested_baseline, live_if_today=False)
if baseline_total is None or baseline_total <= 0:
requested_baseline = earliest_snapshot_date
requested_baseline = earliest_date
baseline_total = get_total_value_asof(requested_baseline, live_if_today=False)
if baseline_total is None or baseline_total <= 0:
return None
@@ -663,13 +800,7 @@ def _build_performance_chart_data() -> Optional[str]:
if not chart_dates:
return None
refresh_needed = not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=requested_baseline).exists()
if refresh_needed:
refresh_benchmark_prices()
colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
percentage_datasets = []
total_values = []
total_percent = []
for day in chart_dates:
@@ -726,9 +857,8 @@ def _build_performance_chart_data() -> Optional[str]:
if value_ds:
value_datasets.append(value_ds)
labels = [day.strftime('%b %-d') for day in chart_dates]
return json.dumps({
'labels': labels,
return {
'labels': [day.strftime('%b %-d') for day in chart_dates],
'baseline_date': requested_baseline.isoformat(),
'modes': {
'percentage': {
@@ -742,6 +872,58 @@ def _build_performance_chart_data() -> Optional[str]:
'datasets': value_datasets,
},
},
}
def _build_performance_chart_data() -> Optional[str]:
all_snaps = list(PortfolioSnapshot.objects.select_related('portfolio').order_by('captured_at'))
if not all_snaps:
return None
portfolio_weekly: dict[int, dict[tuple[int, int], tuple[date_cls, float]]] = {}
week_label_date = {}
for snap in all_snaps:
day = _as_date(snap.captured_at)
key = day.isocalendar()[:2]
portfolio_weekly.setdefault(snap.portfolio_id, {})
existing = portfolio_weekly[snap.portfolio_id].get(key)
if existing is None or day > existing[0]:
portfolio_weekly[snap.portfolio_id][key] = (day, float(snap.total_value))
if key not in week_label_date or day > week_label_date[key]:
week_label_date[key] = day
if not week_label_date:
return None
earliest_snapshot_date = min(week_label_date.values())
latest_date = max(week_label_date.values())
# 一次刷新 benchmark 缓存(覆盖最早的 baseline
earliest_baseline = _baseline_for_period('ALL', latest_date, earliest_snapshot_date)
if not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=earliest_baseline).exists():
refresh_benchmark_prices()
colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
periods = {}
for period in PERIOD_LABELS:
chart = _build_chart_period(
period, week_label_date, portfolio_weekly,
earliest_snapshot_date, latest_date, colors,
)
if chart:
periods[period] = chart
if not periods:
return None
default = 'YTD' if 'YTD' in periods else next(iter(periods))
ytd = periods.get(default, {})
return json.dumps({
'default': default,
'periods': periods,
# 顶层保持默认区间数据(向后兼容)
'labels': ytd.get('labels', []),
'baseline_date': ytd.get('baseline_date'),
'modes': ytd.get('modes', {}),
})
+133 -3
View File
@@ -1,15 +1,23 @@
"""Template views for the invest app."""
import logging
from decimal import Decimal
from django.shortcuts import get_object_or_404, render
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,
@@ -31,16 +39,36 @@ def dashboard(request):
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()
recent_transactions = Transaction.objects.select_related('portfolio').order_by('-date', '-created_at')[:30]
# 全局未实现盈亏汇总
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()
@@ -59,7 +87,12 @@ def dashboard(request):
'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,
})
@@ -78,12 +111,16 @@ def portfolio_detail(request, pk):
'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)
@@ -92,3 +129,96 @@ def portfolio_transactions(request, pk):
'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': '添加现金流记录'})
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static i18n widget_tweaks %}
{% block title %}{{ form_title }} - Invest{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{% url 'invest-dashboard' %}" class="text-stone-400 hover:text-stone-700 text-sm"><i class="fas fa-arrow-left mr-1"></i> 返回仪表盘</a>
</div>
<div class="max-w-xl bg-white rounded-xl shadow-sm p-6">
<h1 class="text-xl font-bold text-stone-900 mb-1">{{ form_title }}</h1>
<p class="text-xs text-stone-400 mb-6">入金 / 出金 / 分红等外部现金流,用于现金流调整收益和净投入计算。</p>
<form method="post" class="space-y-4">
{% csrf_token %}
{% for field in form %}
<div>
<label class="block text-xs font-semibold text-stone-500 uppercase tracking-widest mb-1.5">{{ field.label }}</label>
{{ field|add_class:"w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" }}
{% if field.help_text %}<p class="text-xs text-stone-400 mt-1">{{ field.help_text }}</p>{% endif %}
{% if field.errors %}<p class="text-xs text-red-600 mt-1">{{ field.errors.0 }}</p>{% endif %}
</div>
{% endfor %}
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg px-4 py-2">保存</button>
<a href="{% url 'invest-dashboard' %}" class="text-sm text-stone-500 hover:text-stone-700">取消</a>
</div>
</form>
</div>
{% endblock %}
+138 -66
View File
@@ -17,22 +17,25 @@
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Total Value</p>
{% if overview.this_week_total is not None %}
<p class="text-3xl font-bold text-stone-900">${{ overview.this_week_total|floatformat:0|intcomma }}</p>
<p class="text-3xl font-bold text-stone-900 tabular-nums">${{ overview.this_week_total|floatformat:0|intcomma }}</p>
{% else %}
<p class="text-3xl font-bold text-stone-400"></p>
{% endif %}
<p class="text-sm text-stone-400 mt-1">Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}</p>
{% if aud_total and aud_total.total_aud %}
<p class="text-sm font-semibold text-stone-600 mt-1 tabular-nums">≈ A${{ aud_total.total_aud|floatformat:0|intcomma }}</p>
{% endif %}
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Net Contributions</p>
<p class="text-3xl font-bold text-stone-900">${{ net_contributions|floatformat:0|intcomma }}</p>
<p class="text-3xl font-bold text-stone-900 tabular-nums">${{ net_contributions|floatformat:0|intcomma }}</p>
<p class="text-sm text-stone-400 mt-1">External deposits minus withdrawals</p>
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Investment Gain</p>
<p class="text-3xl font-bold {% if performance.cash_adjusted_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
<p class="text-3xl font-bold tabular-nums {% if performance.cash_adjusted_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0|intcomma }}
</p>
<p class="text-sm text-stone-400 mt-1">Cash-flow adjusted</p>
@@ -40,18 +43,18 @@
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Top 5 Concentration</p>
<p class="text-3xl font-bold {% if risk.concentration_level == 'HIGH' %}text-red-700{% elif risk.concentration_level == 'MEDIUM' %}text-amber-700{% else %}text-green-800{% endif %}">
<p class="text-3xl font-bold tabular-nums {% if risk.concentration_level == 'HIGH' %}text-red-700{% elif risk.concentration_level == 'MEDIUM' %}text-amber-700{% else %}text-green-800{% endif %}">
{% widthratio risk.top_5_weight 1 100 %}%
</p>
<p class="text-sm text-stone-400 mt-1">Risk: {{ risk.concentration_level }}</p>
</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 mb-3">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">This Week</p>
{% if overview.week_gain is not None %}
<p class="text-2xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
<p class="text-2xl font-bold tabular-nums {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0|intcomma }}
</p>
<p class="text-sm text-stone-400 mt-1">{% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last snapshot</p>
@@ -64,14 +67,27 @@
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Money Weighted Return</p>
{% if performance.money_weighted_return is not None %}
<p class="text-2xl font-bold text-stone-900">{% widthratio performance.money_weighted_return 1 100 %}%</p>
<p class="text-2xl font-bold text-stone-900 tabular-nums">{% widthratio performance.money_weighted_return 1 100 %}%</p>
{% else %}
<p class="text-2xl font-bold text-stone-400"></p>
{% endif %}
<p class="text-sm text-stone-400 mt-1">IRR based on cash flows</p>
</div>
<div class="col-span-2 lg:col-span-1 bg-white rounded-lg p-5 shadow-sm">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Unrealized P&L</p>
{% if total_pnl is not None %}
<p class="text-2xl font-bold tabular-nums {% if total_pnl >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if total_pnl >= 0 %}+{% endif %}${{ total_pnl|floatformat:0|intcomma }}
</p>
<p class="text-sm text-stone-400 mt-1">{% if total_pnl_pct >= 0 %}+{% endif %}{{ total_pnl_pct|floatformat:1 }}% vs cost basis</p>
{% else %}
<p class="text-2xl font-bold text-stone-400"></p>
<p class="text-sm text-stone-400 mt-1">No cost data</p>
{% endif %}
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Last Snapshot</p>
{% if overview.this_week_date %}
<p class="text-2xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
@@ -89,12 +105,12 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<p class="text-sm text-stone-400">Actual end value</p>
<p class="text-xl font-bold text-stone-900">${{ performance.end_value|floatformat:0|intcomma }}</p>
<p class="text-xl font-bold text-stone-900 tabular-nums">${{ performance.end_value|floatformat:0|intcomma }}</p>
</div>
{% for ticker, bench in performance.benchmarks.items %}
<div>
<p class="text-sm text-stone-400">Same cash flows into {{ ticker }}</p>
<p class="text-xl font-bold text-stone-900">${{ bench.end_value|floatformat:0|intcomma }}</p>
<p class="text-xl font-bold text-stone-900 tabular-nums">${{ bench.end_value|floatformat:0|intcomma }}</p>
<p class="text-xs text-stone-400">Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}</p>
</div>
{% empty %}
@@ -106,18 +122,27 @@
<!-- ── Performance chart ─────────────────────────────────────── -->
{% if chart_data_json != 'null' %}
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between mb-4">
<div>
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} Performance vs Benchmarks</p>
<p class="text-xs text-stone-400 mt-1">Toggle between percentage return and dollar-value growth from the baseline date.</p>
<p id="performanceChartHelp" class="text-xs text-stone-400 mt-1">Toggle between percentage return and dollar-value growth from the baseline date.</p>
</div>
<div class="inline-flex rounded-lg border border-stone-200 bg-stone-50 p-1 text-xs font-semibold" role="group" aria-label="Chart mode">
<button type="button" id="chartModePercent" class="chart-mode-btn rounded-md px-3 py-1.5 bg-white text-stone-900 shadow-sm" data-mode="percentage">% Growth</button>
<button type="button" id="chartModeValue" class="chart-mode-btn rounded-md px-3 py-1.5 text-stone-500" data-mode="value">$ Value</button>
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex rounded-lg border border-stone-200 bg-stone-50 p-1 text-xs font-semibold" role="group" aria-label="Chart period">
<button type="button" class="period-btn rounded-md px-2.5 py-1.5 text-stone-500" data-period="1M">1M</button>
<button type="button" class="period-btn rounded-md px-2.5 py-1.5 text-stone-500" data-period="3M">3M</button>
<button type="button" class="period-btn rounded-md px-2.5 py-1.5 text-stone-500" data-period="6M">6M</button>
<button type="button" class="period-btn rounded-md px-2.5 py-1.5 text-stone-500" data-period="YTD">YTD</button>
<button type="button" class="period-btn rounded-md px-2.5 py-1.5 text-stone-500" data-period="1Y">1Y</button>
<button type="button" class="period-btn rounded-md px-2.5 py-1.5 text-stone-500" data-period="ALL">ALL</button>
</div>
<div class="inline-flex rounded-lg border border-stone-200 bg-stone-50 p-1 text-xs font-semibold" role="group" aria-label="Chart mode">
<button type="button" id="chartModePercent" class="chart-mode-btn rounded-md px-3 py-1.5 bg-white text-stone-900 shadow-sm" data-mode="percentage">% Growth</button>
<button type="button" id="chartModeValue" class="chart-mode-btn rounded-md px-3 py-1.5 text-stone-500" data-mode="value">$ Value</button>
</div>
</div>
</div>
<canvas id="performanceChart" height="90"></canvas>
<p id="performanceChartHelp" class="text-xs text-stone-400 mt-3">Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.</p>
</div>
{% endif %}
@@ -125,10 +150,10 @@
<div class="bg-white rounded-lg shadow-sm p-5 mb-4">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Risk Overview</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div><p class="text-xs text-stone-400">Top 1</p><p class="font-bold">{% widthratio risk.top_1_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Top 3</p><p class="font-bold">{% widthratio risk.top_3_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Semiconductors</p><p class="font-bold">{% widthratio risk.theme_exposure.semiconductors 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">AI / Cloud</p><p class="font-bold">{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Top 1</p><p class="font-bold tabular-nums">{% widthratio risk.top_1_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Top 3</p><p class="font-bold tabular-nums">{% widthratio risk.top_3_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Semiconductors</p><p class="font-bold tabular-nums">{% widthratio risk.theme_exposure.semiconductors 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">AI / Cloud</p><p class="font-bold tabular-nums">{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%</p></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
@@ -143,9 +168,9 @@
<tbody>
{% for position in risk.top_positions|slice:":5" %}
<tr class="border-b border-stone-50">
<td class="py-2 font-medium">{{ position.stock_code }}</td>
<td class="py-2 text-right">${{ position.current_value|floatformat:0|intcomma }}</td>
<td class="py-2 text-right">{% widthratio position.weight 1 100 %}%</td>
<td class="py-2 font-medium"><a href="{% url 'invest-stock-detail' position.stock_code %}" class="text-blue-700 hover:underline">{{ position.stock_code }}</a></td>
<td class="py-2 text-right tabular-nums">${{ position.current_value|floatformat:0|intcomma }}</td>
<td class="py-2 text-right tabular-nums">{% widthratio position.weight 1 100 %}%</td>
<td class="py-2 text-stone-500">{{ position.portfolio_names|join:", " }}</td>
</tr>
{% endfor %}
@@ -154,7 +179,7 @@
</div>
</div>
<p class="text-xs text-stone-400 text-center mt-2 mb-6">Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync</p>
<p class="text-xs text-stone-400 text-center mt-2 mb-6">Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Cost basis estimated from trade-date close when no price was recorded</p>
<!-- ── Live holdings ─────────────────────────────────────────── -->
{% if all_holdings %}
@@ -167,36 +192,40 @@
<p class="text-xs text-stone-400 mt-0.5">{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}</p>
</div>
<div class="text-right">
<p class="font-bold text-stone-900 text-sm">{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</p>
<p class="font-bold text-stone-900 text-sm tabular-nums">{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</p>
{% if group.change is not None %}
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0|intcomma }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)</p>
<p class="text-xs font-medium mt-0.5 tabular-nums {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0|intcomma }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)</p>
{% else %}
<p class="text-xs text-stone-300 mt-0.5">No prior snapshot</p>
{% endif %}
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="px-6 py-3 text-left">Ticker</th>
<th class="px-6 py-3 text-right">Qty</th>
<th class="px-6 py-3 text-right">Price</th>
<th class="px-6 py-3 text-right">Week Change</th>
<th class="px-6 py-3 text-right">Avg Cost</th>
<th class="px-6 py-3 text-right">P&amp;L</th>
<th class="px-6 py-3 text-right">Mkt Value</th>
</tr>
</thead>
<tbody>
{% for stock in group.holdings %}
<tr class="border-b border-stone-50 hover:bg-stone-50 {{ group.colors.row }}">
<td class="px-6 py-3 font-medium text-stone-900"><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">{{ stock.stock_code }}</span></td>
<td class="px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:0|intcomma }}</td>
<td class="px-6 py-3 text-right text-stone-500">{% if stock.current_price %}${{ stock.current_price|floatformat:2|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 text-right {% if stock.value_change >= 0 %}text-green-700{% elif stock.value_change < 0 %}text-red-600{% else %}text-stone-300{% endif %}">{% if stock.value_change is not None %}{% if stock.value_change >= 0 %}+{% endif %}${{ stock.value_change|floatformat:0|intcomma }} ({% if stock.price_change_pct >= 0 %}+{% endif %}{{ stock.price_change_pct|floatformat:1 }}%){% else %}—{% endif %}</td>
<td class="px-6 py-3 text-right font-semibold text-stone-800">{% if stock.current_value %}${{ stock.current_value|floatformat:0|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 font-medium text-stone-900"><a href="{% url 'invest-stock-detail' stock.stock_code %}" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }} hover:opacity-75">{{ stock.stock_code }}</a></td>
<td class="px-6 py-3 text-right text-stone-500 tabular-nums">{{ stock.quantity|floatformat:0|intcomma }}</td>
<td class="px-6 py-3 text-right text-stone-500 tabular-nums">{% if stock.current_price %}${{ stock.current_price|floatformat:2|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 text-right text-stone-500 tabular-nums">{% if stock.cost.avg_cost %}${{ stock.cost.avg_cost|floatformat:2|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 text-right tabular-nums {% if stock.cost.unrealized_pnl >= 0 %}text-green-700{% elif stock.cost.unrealized_pnl < 0 %}text-red-600{% else %}text-stone-300{% endif %}">{% if stock.cost.unrealized_pnl is not None %}{% if stock.cost.unrealized_pnl >= 0 %}+{% endif %}${{ stock.cost.unrealized_pnl|floatformat:0|intcomma }} ({% if stock.cost.pnl_pct >= 0 %}+{% endif %}{{ stock.cost.pnl_pct|floatformat:1 }}%){% else %}—{% endif %}</td>
<td class="px-6 py-3 text-right font-semibold text-stone-800 tabular-nums">{% if stock.current_value %}${{ stock.current_value|floatformat:0|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
@@ -231,9 +260,25 @@
{% endif %}
<!-- ── Transactions ──────────────────────────────────────────── -->
{% if recent_transactions %}
{% if recent_transactions or search_q %}
<div class="bg-white rounded-lg shadow-sm p-5 mt-4">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Transaction History</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div>
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">Transaction History</p>
<p class="text-xs text-stone-400 mt-0.5">AI/OCR 同步或手动录入 · 成本价缺失时按交易日收盘价估算</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<form method="get" class="flex items-center gap-1.5">
<input type="text" name="q" value="{{ search_q }}" placeholder="搜索 ticker / 账户"
class="text-xs border border-stone-200 rounded-lg px-2.5 py-1.5 w-36 focus:outline-none focus:ring-2 focus:ring-blue-200">
<button type="submit" class="text-xs font-semibold text-blue-700 hover:text-blue-900 px-1">搜索</button>
{% if search_q %}<a href="{% url 'invest-dashboard' %}" class="text-xs text-stone-400 hover:text-stone-600">清除</a>{% endif %}
</form>
<a href="{% url 'invest-transaction-create' %}" class="text-xs font-semibold bg-blue-600 hover:bg-blue-700 text-white rounded-lg px-2.5 py-1.5">+ 交易</a>
<a href="{% url 'invest-cashflow-create' %}" class="text-xs font-semibold border border-stone-200 text-stone-600 hover:bg-stone-50 rounded-lg px-2.5 py-1.5">+ 现金流</a>
</div>
</div>
{% if recent_transactions %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
@@ -253,11 +298,11 @@
<tr class="border-b border-stone-50">
<td class="py-2">{{ tx.date|date:"j M Y" }}</td>
<td class="py-2">{{ tx.portfolio.name }}</td>
<td class="py-2">{{ tx.action }}</td>
<td class="py-2 font-medium">{{ tx.stock_code }}</td>
<td class="py-2 text-right">{{ tx.quantity|floatformat:0|intcomma }}</td>
<td class="py-2 text-right">{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2|intcomma }}{% else %}<span class="text-stone-300">optional</span>{% endif %}</td>
<td class="py-2 text-right">{% if tx.fee %}${{ tx.fee|floatformat:2|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="py-2 {% if tx.action == 'BUY' %}text-green-700{% else %}text-red-600{% endif %} font-medium">{{ tx.action }}</td>
<td class="py-2 font-medium"><a href="{% url 'invest-stock-detail' tx.stock_code %}" class="text-blue-700 hover:underline">{{ tx.stock_code }}</a></td>
<td class="py-2 text-right tabular-nums">{{ tx.quantity|floatformat:0|intcomma }}</td>
<td class="py-2 text-right tabular-nums">{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2|intcomma }}{% else %}<span class="text-stone-300">~估算</span>{% endif %}</td>
<td class="py-2 text-right tabular-nums">{% if tx.fee %}${{ tx.fee|floatformat:2|intcomma }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="py-2">
{% if tx.source %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium {% if tx.source|lower == 'ai' or tx.source|lower == 'ocr' %}bg-purple-100 text-purple-700{% else %}bg-stone-100 text-stone-500{% endif %}">
@@ -273,6 +318,11 @@
</tbody>
</table>
</div>
{% else %}
<div class="py-8 text-center">
<p class="text-sm text-stone-400">没有匹配 "{{ search_q }}" 的交易</p>
</div>
{% endif %}
</div>
{% endif %}
@@ -283,12 +333,14 @@
<script>
(function () {
const raw = {{ chart_data_json|safe }};
if (!raw || !raw.modes) return;
if (!raw || !raw.periods) return;
const ctx = document.getElementById('performanceChart');
if (!ctx) return;
const help = document.getElementById('performanceChartHelp');
const buttons = Array.from(document.querySelectorAll('.chart-mode-btn'));
const periods = raw.periods;
const defaultPeriod = raw.default || 'YTD';
let currentPeriod = periods[defaultPeriod] ? defaultPeriod : Object.keys(periods)[0];
let currentMode = 'percentage';
function formatCurrency(value) {
@@ -300,16 +352,15 @@
return sign + value.toFixed(2) + '%';
}
function chartDataFor(mode) {
const modeData = raw.modes[mode] || raw.modes.percentage;
return {
labels: raw.labels,
datasets: modeData.datasets,
};
function currentData() {
const period = periods[currentPeriod];
const modes = period.modes;
const modeData = modes[currentMode] || modes.percentage;
return { labels: period.labels, datasets: modeData.datasets, unit: modeData.unit, baseline: period.baseline_date };
}
function chartOptionsFor(mode) {
const unit = (raw.modes[mode] || raw.modes.percentage).unit;
function chartOptions() {
const unit = currentData().unit;
return {
responsive: true,
interaction: { mode: 'index', intersect: false },
@@ -326,32 +377,53 @@
const chart = new Chart(ctx, {
type: 'line',
data: chartDataFor(currentMode),
options: chartOptionsFor(currentMode),
data: currentData(),
options: chartOptions(),
});
function setMode(mode) {
currentMode = mode;
chart.data = chartDataFor(mode);
chart.options = chartOptionsFor(mode);
function render() {
chart.data = currentData();
chart.options = chartOptions();
chart.update();
buttons.forEach((button) => {
const active = button.dataset.mode === mode;
button.classList.toggle('bg-white', active);
button.classList.toggle('text-stone-900', active);
button.classList.toggle('shadow-sm', active);
button.classList.toggle('text-stone-500', !active);
});
if (help) {
const baseline = raw.baseline_date || 'the baseline date';
help.textContent = mode === 'value'
const baseline = currentData().baseline || 'the baseline date';
help.textContent = currentMode === 'value'
? `Dollar mode: actual total portfolio value vs SPY/QQQ benchmark value from ${baseline}.`
: `Percentage mode: growth/decline since ${baseline}; cash-flow-adjusted metrics are shown in the cards above.`;
}
}
buttons.forEach((button) => button.addEventListener('click', () => setMode(button.dataset.mode)));
setMode(currentMode);
function refreshToggleButtons(buttons, activeKey) {
buttons.forEach((button) => {
const active = button.dataset.period === activeKey || button.dataset.mode === activeKey;
button.classList.toggle('bg-white', active);
button.classList.toggle('text-stone-900', active);
button.classList.toggle('shadow-sm', active);
button.classList.toggle('text-stone-500', !active);
});
}
document.querySelectorAll('.period-btn').forEach((button) => {
button.addEventListener('click', () => {
if (!periods[button.dataset.period]) return;
currentPeriod = button.dataset.period;
refreshToggleButtons(document.querySelectorAll('.period-btn'), currentPeriod);
render();
});
});
document.querySelectorAll('.chart-mode-btn').forEach((button) => {
button.addEventListener('click', () => {
currentMode = button.dataset.mode;
refreshToggleButtons(document.querySelectorAll('.chart-mode-btn'), currentMode);
render();
});
});
// 初始状态
refreshToggleButtons(document.querySelectorAll('.period-btn'), currentPeriod);
refreshToggleButtons(document.querySelectorAll('.chart-mode-btn'), currentMode);
render();
})();
</script>
{% endif %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% load static i18n humanize %}
{% block title %}{{ ticker }} - Invest{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{% url 'invest-dashboard' %}" class="text-stone-400 hover:text-stone-700 text-sm"><i class="fas fa-arrow-left mr-1"></i> 返回仪表盘</a>
</div>
<!-- Header -->
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<h1 class="text-3xl font-bold text-stone-900">{{ ticker }}</h1>
<p class="text-sm text-stone-400 mt-1">
跨 {{ portfolio_count }} 个账户持仓{% if theme != '—' %} · 主题:{{ theme }}{% endif %}{% if weight is not None %} · 权重 {{ weight|floatformat:1 }}%{% endif %}
</p>
</div>
<div class="text-right">
{% if current_price %}
<p class="text-2xl font-bold text-stone-900 tabular-nums">${{ current_price|floatformat:2|intcomma }}</p>
<p class="text-xs text-stone-400 mt-0.5">当前价格</p>
{% else %}
<p class="text-2xl font-bold text-stone-300"></p>
{% endif %}
</div>
</div>
<!-- Summary cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">持仓数量</p>
<p class="text-2xl font-bold text-stone-900 tabular-nums">{{ total_qty|floatformat:2|intcomma }}</p>
<p class="text-sm text-stone-400 mt-1">{{ positions|length }} 个账户</p>
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Avg Cost</p>
{% if avg_cost %}
<p class="text-2xl font-bold text-stone-900 tabular-nums">${{ avg_cost|floatformat:2|intcomma }}</p>
<p class="text-sm text-stone-400 mt-1">{% if estimated %}按交易日收盘价估算{% else %}按成交价加权{% endif %}</p>
{% else %}
<p class="text-2xl font-bold text-stone-400"></p>
<p class="text-sm text-stone-400 mt-1">无成本数据</p>
{% endif %}
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">市值</p>
<p class="text-2xl font-bold text-stone-900 tabular-nums">${{ total_value|floatformat:0|intcomma }}</p>
<p class="text-sm text-stone-400 mt-1">成本 ${{ total_cost|floatformat:0|intcomma }}</p>
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">未实现盈亏</p>
{% if total_pnl is not None %}
<p class="text-2xl font-bold tabular-nums {% if total_pnl >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if total_pnl >= 0 %}+{% endif %}${{ total_pnl|floatformat:0|intcomma }}
</p>
<p class="text-sm mt-1 tabular-nums {% if total_pnl >= 0 %}text-green-700{% else %}text-red-600{% endif %}">{% if pnl_pct >= 0 %}+{% endif %}{{ pnl_pct|floatformat:1 }}%</p>
{% else %}
<p class="text-2xl font-bold text-stone-400"></p>
<p class="text-sm text-stone-400 mt-1">无成本数据</p>
{% endif %}
</div>
</div>
<!-- Per-account positions -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden mb-4">
<div class="px-6 py-4 border-b border-stone-100">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">各账户持仓</p>
</div>
{% if positions %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="px-6 py-3 text-left">账户</th>
<th class="px-6 py-3 text-right">数量</th>
<th class="px-6 py-3 text-right">市值</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-50">
{% for pos in positions %}
<tr class="hover:bg-stone-50">
<td class="px-6 py-3 font-semibold text-stone-900">{{ pos.portfolio.name }}</td>
<td class="px-6 py-3 text-right text-stone-600 tabular-nums">{{ pos.quantity|floatformat:2|intcomma }}</td>
<td class="px-6 py-3 text-right font-medium text-stone-900 tabular-nums">${{ pos.current_value|floatformat:0|intcomma }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<p class="text-stone-400">当前无持仓(可能有历史交易)</p>
</div>
{% endif %}
</div>
<!-- Transactions -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">交易历史</p>
<a href="{% url 'invest-transaction-create' %}" class="text-xs font-semibold text-blue-700 hover:text-blue-900">+ 添加交易</a>
</div>
{% if transactions %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="px-6 py-3 text-left">日期</th>
<th class="px-6 py-3 text-left">账户</th>
<th class="px-6 py-3 text-left">类型</th>
<th class="px-6 py-3 text-right">数量</th>
<th class="px-6 py-3 text-right">价格</th>
<th class="px-6 py-3 text-right">手续费</th>
<th class="px-6 py-3 text-left">来源</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-50">
{% for tx in transactions %}
<tr class="hover:bg-stone-50">
<td class="px-6 py-3 text-stone-500">{{ tx.date|date:"Y-m-d" }}</td>
<td class="px-6 py-3 text-stone-600">{{ tx.portfolio.name }}</td>
<td class="px-6 py-3 {% if tx.action == 'BUY' %}text-green-700{% else %}text-red-600{% endif %} font-medium">{{ tx.action }}</td>
<td class="px-6 py-3 text-right text-stone-600 tabular-nums">{{ tx.quantity|floatformat:2|intcomma }}</td>
<td class="px-6 py-3 text-right text-stone-600 tabular-nums">{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2|intcomma }}{% else %}<span class="text-stone-300">~估算</span>{% endif %}</td>
<td class="px-6 py-3 text-right text-stone-500 tabular-nums">{% if tx.fee %}${{ tx.fee|floatformat:2|intcomma }}{% else %}—{% endif %}</td>
<td class="px-6 py-3">
{% if tx.source %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium {% if tx.source|lower == 'ai' or tx.source|lower == 'ocr' %}bg-purple-100 text-purple-700{% else %}bg-stone-100 text-stone-500{% endif %}">
{% if tx.source|lower == 'ai' or tx.source|lower == 'ocr' %}🤖 AI{% else %}手动{% endif %}
</span>
{% if tx.confidence is not None and tx.confidence < 0.9 %}
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700">⚠ 低置信</span>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<p class="text-stone-400">暂无交易记录</p>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static i18n widget_tweaks %}
{% block title %}{{ form_title }} - Invest{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{% url 'invest-dashboard' %}" class="text-stone-400 hover:text-stone-700 text-sm"><i class="fas fa-arrow-left mr-1"></i> 返回仪表盘</a>
</div>
<div class="max-w-xl bg-white rounded-xl shadow-sm p-6">
<h1 class="text-xl font-bold text-stone-900 mb-1">{{ form_title }}</h1>
<p class="text-xs text-stone-400 mb-6">价格留空时,成本将按交易日的市场收盘价估算。</p>
<form method="post" class="space-y-4">
{% csrf_token %}
{% for field in form %}
<div>
<label class="block text-xs font-semibold text-stone-500 uppercase tracking-widest mb-1.5">{{ field.label }}</label>
{{ field|add_class:"w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" }}
{% if field.help_text %}<p class="text-xs text-stone-400 mt-1">{{ field.help_text }}</p>{% endif %}
{% if field.errors %}<p class="text-xs text-red-600 mt-1">{{ field.errors.0 }}</p>{% endif %}
</div>
{% endfor %}
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg px-4 py-2">保存</button>
<a href="{% url 'invest-dashboard' %}" class="text-sm text-stone-500 hover:text-stone-700">取消</a>
</div>
</form>
</div>
{% endblock %}
+3
View File
@@ -20,4 +20,7 @@ urlpatterns = [
path('', template_views.dashboard, name='invest-dashboard'),
path('portfolios/<int:pk>/', template_views.portfolio_detail, name='invest-portfolio-detail'),
path('portfolios/<int:pk>/transactions/', template_views.portfolio_transactions, name='invest-portfolio-transactions'),
path('stocks/<str:ticker>/', template_views.stock_detail, name='invest-stock-detail'),
path('transactions/new/', template_views.transaction_create, name='invest-transaction-create'),
path('cashflows/new/', template_views.cashflow_create, name='invest-cashflow-create'),
]
+187
View File
@@ -0,0 +1,187 @@
"""Tests for cost basis / P&L, per-ticker page, manual entry forms, search."""
from datetime import date
from decimal import Decimal
import pytest
from invest.models import CashFlow, Portfolio, Stock, Transaction
from invest.services import get_cost_basis
@pytest.fixture
def portfolio():
return Portfolio.objects.create(name="Test Broker")
@pytest.mark.django_db
def test_cost_basis_wac_buy_then_sell(portfolio, monkeypatch):
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 150.0)
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("10"),
price_per_share=Decimal("100"), date=date(2026, 5, 1),
)
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("10"),
price_per_share=Decimal("120"), date=date(2026, 5, 10),
)
Transaction.objects.create(
portfolio=portfolio, action="SELL", stock_code="NVDA", quantity=Decimal("5"),
price_per_share=Decimal("140"), date=date(2026, 6, 1),
)
result = get_cost_basis()
rows = result["flattened"]
assert len(rows) == 1
row = rows[0]
# WAC = (10*100 + 10*120) / 20 = 110; 卖 5 后剩 15,成本 = 15*110 = 1650
assert row["stock_code"] == "NVDA"
assert row["quantity"] == pytest.approx(15)
assert row["avg_cost"] == pytest.approx(110.0)
assert row["total_cost"] == pytest.approx(1650.0)
assert row["current_value"] == pytest.approx(2250.0)
assert row["unrealized_pnl"] == pytest.approx(600.0)
assert row["pnl_pct"] == pytest.approx(600 / 1650 * 100, rel=1e-3)
@pytest.mark.django_db
def test_cost_basis_estimates_price_from_trade_date(portfolio, monkeypatch):
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 200.0)
# 交易无价格 → 用交易日历史收盘价估算
monkeypatch.setattr(
"invest.services._get_historical_price",
lambda ticker, ref_date: 80.0,
)
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="MU", quantity=Decimal("10"),
date=date(2026, 5, 1),
)
row = get_cost_basis()["flattened"][0]
assert row["avg_cost"] == pytest.approx(80.0)
assert row["estimated"] is True
assert row["unrealized_pnl"] == pytest.approx(1200.0)
@pytest.mark.django_db
def test_cost_basis_marks_incomplete_when_no_price_at_all(portfolio, monkeypatch):
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 200.0)
monkeypatch.setattr("invest.services._get_historical_price", lambda ticker, ref_date: None)
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="AAA", quantity=Decimal("10"),
date=date(2026, 5, 1),
)
rows = get_cost_basis()["flattened"]
assert len(rows) == 0 # 无价格可算 → 不产出成本行
# 但明细仍标记 incomplete 供提示
assert len(rows) == 0
@pytest.mark.django_db
def test_cost_basis_aggregates_per_portfolio(portfolio, monkeypatch):
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 100.0)
other = Portfolio.objects.create(name="Other Broker")
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="MRVL", quantity=Decimal("20"),
price_per_share=Decimal("50"), date=date(2026, 5, 1),
)
Transaction.objects.create(
portfolio=other, action="BUY", stock_code="MRVL", quantity=Decimal("30"),
price_per_share=Decimal("60"), date=date(2026, 5, 2),
)
result = get_cost_basis()
assert len(result["flattened"]) == 2 # 两个账户各自一行
assert set(result["by_portfolio"].keys()) == {portfolio.id, other.id}
@pytest.mark.django_db
def test_stock_detail_page_shows_cross_account_pnl(client, portfolio, monkeypatch):
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 120.0)
other = Portfolio.objects.create(name="Other Broker")
Stock.objects.create(portfolio=portfolio, stock_code="NVDA", quantity=Decimal("10"))
Stock.objects.create(portfolio=other, stock_code="NVDA", quantity=Decimal("5"))
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("10"),
price_per_share=Decimal("80"), date=date(2026, 5, 1),
)
Transaction.objects.create(
portfolio=other, action="BUY", stock_code="NVDA", quantity=Decimal("5"),
price_per_share=Decimal("100"), date=date(2026, 5, 2),
)
response = client.get("/invest/stocks/NVDA/")
assert response.status_code == 200
content = response.content.decode()
assert "NVDA" in content
# 总持仓 15、市值 1800、成本 1300、盈亏 500
assert "$1,800" in content
assert "$1,300" in content
assert "+$500" in content
assert "Test Broker" in content
assert "Other Broker" in content
@pytest.mark.django_db
def test_manual_transaction_form_creates_record(client, portfolio):
response = client.post(
"/invest/transactions/new/",
{
"portfolio": portfolio.id,
"action": "BUY",
"stock_code": "nvda",
"quantity": "3",
"price_per_share": "90.5",
"currency": "USD",
"fee": "1.5",
"date": "2026-07-30",
},
)
assert response.status_code == 302 # redirect to dashboard
tx = Transaction.objects.get()
assert tx.stock_code == "NVDA" # 大写化
assert tx.source == "MANUAL"
assert tx.confidence == Decimal("1.0")
assert tx.price_per_share == Decimal("90.5")
@pytest.mark.django_db
def test_manual_cashflow_form_creates_record(client, portfolio):
response = client.post(
"/invest/cashflows/new/",
{
"portfolio": portfolio.id,
"flow_type": "DEPOSIT",
"amount": "2000",
"currency": "AUD",
"date": "2026-07-31",
"note": "test deposit",
},
)
assert response.status_code == 302
flow = CashFlow.objects.get()
assert flow.source == "MANUAL"
assert flow.amount == Decimal("2000.00")
assert flow.flow_type == "DEPOSIT"
@pytest.mark.django_db
def test_dashboard_transaction_search_filters(client, portfolio):
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="NVDA", quantity=Decimal("1"),
price_per_share=Decimal("100"), date=date(2026, 7, 1),
)
Transaction.objects.create(
portfolio=portfolio, action="BUY", stock_code="AMD", quantity=Decimal("1"),
price_per_share=Decimal("200"), date=date(2026, 7, 2),
)
response = client.get("/invest/?q=amd")
assert response.status_code == 200
content = response.content.decode()
assert "AMD" in content
assert "NVDA" not in content