diff --git a/invest/forms.py b/invest/forms.py new file mode 100644 index 0000000..107ab4b --- /dev/null +++ b/invest/forms.py @@ -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 diff --git a/invest/services.py b/invest/services.py index afe365a..070f25b 100644 --- a/invest/services.py +++ b/invest/services.py @@ -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', {}), }) diff --git a/invest/template_views.py b/invest/template_views.py index 0ad5587..a4e6560 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -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': '添加现金流记录'}) diff --git a/invest/templates/invest/cashflow_form.html b/invest/templates/invest/cashflow_form.html new file mode 100644 index 0000000..531cdd1 --- /dev/null +++ b/invest/templates/invest/cashflow_form.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load static i18n widget_tweaks %} + +{% block title %}{{ form_title }} - Invest{% endblock %} + +{% block content %} +
+ 返回仪表盘 +
+ +
+

{{ form_title }}

+

入金 / 出金 / 分红等外部现金流,用于现金流调整收益和净投入计算。

+ +
+ {% csrf_token %} + {% for field in form %} +
+ + {{ 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 %}

{{ field.help_text }}

{% endif %} + {% if field.errors %}

{{ field.errors.0 }}

{% endif %} +
+ {% endfor %} +
+ + 取消 +
+
+
+{% endblock %} diff --git a/invest/templates/invest/dashboard.html b/invest/templates/invest/dashboard.html index 2acf6dc..bd7c837 100644 --- a/invest/templates/invest/dashboard.html +++ b/invest/templates/invest/dashboard.html @@ -17,22 +17,25 @@

Total Value

{% if overview.this_week_total is not None %} -

${{ overview.this_week_total|floatformat:0|intcomma }}

+

${{ overview.this_week_total|floatformat:0|intcomma }}

{% else %}

{% endif %}

Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}

+ {% if aud_total and aud_total.total_aud %} +

≈ A${{ aud_total.total_aud|floatformat:0|intcomma }}

+ {% endif %}

Net Contributions

-

${{ net_contributions|floatformat:0|intcomma }}

+

${{ net_contributions|floatformat:0|intcomma }}

External deposits minus withdrawals

Investment Gain

-

+

{% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0|intcomma }}

Cash-flow adjusted

@@ -40,18 +43,18 @@

Top 5 Concentration

-

+

{% widthratio risk.top_5_weight 1 100 %}%

Risk: {{ risk.concentration_level }}

-
+

This Week

{% if overview.week_gain is not None %} -

+

{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0|intcomma }}

{% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last snapshot

@@ -64,14 +67,27 @@

Money Weighted Return

{% if performance.money_weighted_return is not None %} -

{% widthratio performance.money_weighted_return 1 100 %}%

+

{% widthratio performance.money_weighted_return 1 100 %}%

{% else %}

{% endif %}

IRR based on cash flows

-
+
+

Unrealized P&L

+ {% if total_pnl is not None %} +

+ {% if total_pnl >= 0 %}+{% endif %}${{ total_pnl|floatformat:0|intcomma }} +

+

{% if total_pnl_pct >= 0 %}+{% endif %}{{ total_pnl_pct|floatformat:1 }}% vs cost basis

+ {% else %} +

+

No cost data

+ {% endif %} +
+ +

Last Snapshot

{% if overview.this_week_date %}

{{ overview.this_week_date|date:"M j" }}

@@ -89,12 +105,12 @@

Actual end value

-

${{ performance.end_value|floatformat:0|intcomma }}

+

${{ performance.end_value|floatformat:0|intcomma }}

{% for ticker, bench in performance.benchmarks.items %}

Same cash flows into {{ ticker }}

-

${{ bench.end_value|floatformat:0|intcomma }}

+

${{ bench.end_value|floatformat:0|intcomma }}

Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}

{% empty %} @@ -106,18 +122,27 @@ {% if chart_data_json != 'null' %}
-
+

{{ fy_label }} Performance vs Benchmarks

-

Toggle between percentage return and dollar-value growth from the baseline date.

+

Toggle between percentage return and dollar-value growth from the baseline date.

-
- - +
+
+ + + + + + +
+
+ + +
-

Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.

{% endif %} @@ -125,10 +150,10 @@

Risk Overview

-

Top 1

{% widthratio risk.top_1_weight 1 100 %}%

-

Top 3

{% widthratio risk.top_3_weight 1 100 %}%

-

Semiconductors

{% widthratio risk.theme_exposure.semiconductors 1 100 %}%

-

AI / Cloud

{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%

+

Top 1

{% widthratio risk.top_1_weight 1 100 %}%

+

Top 3

{% widthratio risk.top_3_weight 1 100 %}%

+

Semiconductors

{% widthratio risk.theme_exposure.semiconductors 1 100 %}%

+

AI / Cloud

{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%

@@ -143,9 +168,9 @@ {% for position in risk.top_positions|slice:":5" %} - - - + + + {% endfor %} @@ -154,7 +179,7 @@ -

Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync

+

Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Cost basis estimated from trade-date close when no price was recorded

{% if all_holdings %} @@ -167,36 +192,40 @@

{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}

-

{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0|intcomma }}{% else %}{% endif %}

+

{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0|intcomma }}{% else %}{% endif %}

{% if group.change is not None %} -

{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0|intcomma }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)

+

{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0|intcomma }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)

{% else %}

No prior snapshot

{% endif %}
+
{{ position.stock_code }}${{ position.current_value|floatformat:0|intcomma }}{% widthratio position.weight 1 100 %}%{{ position.stock_code }}${{ position.current_value|floatformat:0|intcomma }}{% widthratio position.weight 1 100 %}% {{ position.portfolio_names|join:", " }}
- + + {% for stock in group.holdings %} - - - - - + + + + + + {% endfor %}
Ticker Qty PriceWeek ChangeAvg CostP&L Mkt Value
{{ stock.stock_code }}{{ stock.quantity|floatformat:0|intcomma }}{% if stock.current_price %}${{ stock.current_price|floatformat:2|intcomma }}{% else %}{% 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 %}{% if stock.current_value %}${{ stock.current_value|floatformat:0|intcomma }}{% else %}{% endif %}{{ stock.stock_code }}{{ stock.quantity|floatformat:0|intcomma }}{% if stock.current_price %}${{ stock.current_price|floatformat:2|intcomma }}{% else %}{% endif %}{% if stock.cost.avg_cost %}${{ stock.cost.avg_cost|floatformat:2|intcomma }}{% else %}{% 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 %}{% if stock.current_value %}${{ stock.current_value|floatformat:0|intcomma }}{% else %}{% endif %}
+
{% endfor %}
@@ -231,9 +260,25 @@ {% endif %} -{% if recent_transactions %} +{% if recent_transactions or search_q %}
-

Transaction History

+
+
+

Transaction History

+

AI/OCR 同步或手动录入 · 成本价缺失时按交易日收盘价估算

+
+
+
+ + + {% if search_q %}清除{% endif %} +
+ + 交易 + + 现金流 +
+
+ {% if recent_transactions %}
@@ -253,11 +298,11 @@ - - - - - + + + + +
{{ tx.date|date:"j M Y" }} {{ tx.portfolio.name }}{{ tx.action }}{{ tx.stock_code }}{{ tx.quantity|floatformat:0|intcomma }}{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2|intcomma }}{% else %}optional{% endif %}{% if tx.fee %}${{ tx.fee|floatformat:2|intcomma }}{% else %}{% endif %}{{ tx.action }}{{ tx.stock_code }}{{ tx.quantity|floatformat:0|intcomma }}{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2|intcomma }}{% else %}~估算{% endif %}{% if tx.fee %}${{ tx.fee|floatformat:2|intcomma }}{% else %}{% endif %} {% if tx.source %} @@ -273,6 +318,11 @@
+ {% else %} +
+

没有匹配 "{{ search_q }}" 的交易

+
+ {% endif %}
{% endif %} @@ -283,12 +333,14 @@ {% endif %} diff --git a/invest/templates/invest/stock_detail.html b/invest/templates/invest/stock_detail.html new file mode 100644 index 0000000..7a9567d --- /dev/null +++ b/invest/templates/invest/stock_detail.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% load static i18n humanize %} + +{% block title %}{{ ticker }} - Invest{% endblock %} + +{% block content %} + + + +
+
+

{{ ticker }}

+

+ 跨 {{ portfolio_count }} 个账户持仓{% if theme != '—' %} · 主题:{{ theme }}{% endif %}{% if weight is not None %} · 权重 {{ weight|floatformat:1 }}%{% endif %} +

+
+
+ {% if current_price %} +

${{ current_price|floatformat:2|intcomma }}

+

当前价格

+ {% else %} +

+ {% endif %} +
+
+ + +
+
+

持仓数量

+

{{ total_qty|floatformat:2|intcomma }}

+

{{ positions|length }} 个账户

+
+
+

Avg Cost

+ {% if avg_cost %} +

${{ avg_cost|floatformat:2|intcomma }}

+

{% if estimated %}按交易日收盘价估算{% else %}按成交价加权{% endif %}

+ {% else %} +

+

无成本数据

+ {% endif %} +
+
+

市值

+

${{ total_value|floatformat:0|intcomma }}

+

成本 ${{ total_cost|floatformat:0|intcomma }}

+
+
+

未实现盈亏

+ {% if total_pnl is not None %} +

+ {% if total_pnl >= 0 %}+{% endif %}${{ total_pnl|floatformat:0|intcomma }} +

+

{% if pnl_pct >= 0 %}+{% endif %}{{ pnl_pct|floatformat:1 }}%

+ {% else %} +

+

无成本数据

+ {% endif %} +
+
+ + +
+
+

各账户持仓

+
+ {% if positions %} +
+ + + + + + + + + + {% for pos in positions %} + + + + + + {% endfor %} + +
账户数量市值
{{ pos.portfolio.name }}{{ pos.quantity|floatformat:2|intcomma }}${{ pos.current_value|floatformat:0|intcomma }}
+
+ {% else %} +
+

当前无持仓(可能有历史交易)

+
+ {% endif %} +
+ + +
+
+

交易历史

+ + 添加交易 +
+ {% if transactions %} +
+ + + + + + + + + + + + + + {% for tx in transactions %} + + + + + + + + + + {% endfor %} + +
日期账户类型数量价格手续费来源
{{ tx.date|date:"Y-m-d" }}{{ tx.portfolio.name }}{{ tx.action }}{{ tx.quantity|floatformat:2|intcomma }}{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2|intcomma }}{% else %}~估算{% endif %}{% if tx.fee %}${{ tx.fee|floatformat:2|intcomma }}{% else %}—{% endif %} + {% if tx.source %} + + {% if tx.source|lower == 'ai' or tx.source|lower == 'ocr' %}🤖 AI{% else %}手动{% endif %} + + {% if tx.confidence is not None and tx.confidence < 0.9 %} + ⚠ 低置信 + {% endif %} + {% endif %} +
+
+ {% else %} +
+

暂无交易记录

+
+ {% endif %} +
+{% endblock %} diff --git a/invest/templates/invest/transaction_form.html b/invest/templates/invest/transaction_form.html new file mode 100644 index 0000000..8bba99c --- /dev/null +++ b/invest/templates/invest/transaction_form.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load static i18n widget_tweaks %} + +{% block title %}{{ form_title }} - Invest{% endblock %} + +{% block content %} + + +
+

{{ form_title }}

+

价格留空时,成本将按交易日的市场收盘价估算。

+ +
+ {% csrf_token %} + {% for field in form %} +
+ + {{ 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 %}

{{ field.help_text }}

{% endif %} + {% if field.errors %}

{{ field.errors.0 }}

{% endif %} +
+ {% endfor %} +
+ + 取消 +
+
+
+{% endblock %} diff --git a/invest/urls.py b/invest/urls.py index fdbb9b6..4451e3c 100644 --- a/invest/urls.py +++ b/invest/urls.py @@ -20,4 +20,7 @@ urlpatterns = [ path('', template_views.dashboard, name='invest-dashboard'), path('portfolios//', template_views.portfolio_detail, name='invest-portfolio-detail'), path('portfolios//transactions/', template_views.portfolio_transactions, name='invest-portfolio-transactions'), + path('stocks//', 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'), ] diff --git a/tests/test_invest_cost_basis.py b/tests/test_invest_cost_basis.py new file mode 100644 index 0000000..6c710d0 --- /dev/null +++ b/tests/test_invest_cost_basis.py @@ -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