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 %} +
入金 / 出金 / 分红等外部现金流,用于现金流调整收益和净投入计算。
+ + +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 }}
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 %}
{{ 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.
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 %}%
| {{ 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:", " }} |
| Ticker | Qty | Price | -Week Change | +Avg Cost | +P&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 %} |
Transaction History
+ + {% if recent_transactions %}| {{ 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 @@ |
没有匹配 "{{ search_q }}" 的交易
++ 跨 {{ portfolio_count }} 个账户持仓{% if theme != '—' %} · 主题:{{ theme }}{% endif %}{% if weight is not None %} · 权重 {{ weight|floatformat:1 }}%{% endif %} +
+${{ 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 %} +各账户持仓
+| 账户 | +数量 | +市值 | +
|---|---|---|
| {{ pos.portfolio.name }} | +{{ pos.quantity|floatformat:2|intcomma }} | +${{ pos.current_value|floatformat:0|intcomma }} | +
当前无持仓(可能有历史交易)
+交易历史
+ + 添加交易 +| 日期 | +账户 | +类型 | +数量 | +价格 | +手续费 | +来源 | +
|---|---|---|---|---|---|---|
| {{ 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 %} + | +
暂无交易记录
+价格留空时,成本将按交易日的市场收盘价估算。
+ + +