mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
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:
@@ -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
@@ -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
@@ -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 %}
|
||||
@@ -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&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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user