Investment portfolio charts, search fix, and holding creation fixes
- Add four portfolio charts: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar, return % bar - Fix asset search to use yf.Search() full-text instead of ticker-only lookup — name searches like "vanguard ftse all world" now work - Fix holding creation double-quantity bug: holdings now created with quantity=0 so buy transaction is sole source of quantity/cost basis - Add per-share / total price toggle in Add Holding modal with live calculated equivalent shown as you type - Add ErrorBoundary in AppShell so render errors show a message instead of a blank page - Fix donut charts using || instead of ?? when falling back from current_value to cost_basis_total (0 was not falling through ??) - Allow HoldingCreate.quantity >= 0 (was gt=0) to support zero-init - Fix error display for Pydantic v2 array-of-objects validation errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
26e2a055db
commit
cdc1e67321
9 changed files with 424 additions and 70 deletions
295
frontend/src/pages/investments/PortfolioCharts.tsx
Normal file
295
frontend/src/pages/investments/PortfolioCharts.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import {
|
||||
PieChart, Pie, Cell, Tooltip, ResponsiveContainer,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, ReferenceLine, LabelList,
|
||||
} from "recharts";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import type { PortfolioSummary, HoldingResponse } from "@/api/investments";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
|
||||
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
stock: "#6366f1",
|
||||
etf: "#22c55e",
|
||||
crypto: "#f97316",
|
||||
fund: "#14b8a6",
|
||||
bond: "#f59e0b",
|
||||
other: "#94a3b8",
|
||||
};
|
||||
|
||||
function typeColor(type: string) {
|
||||
return TYPE_COLORS[type.toLowerCase()] ?? TYPE_COLORS.other;
|
||||
}
|
||||
|
||||
function DonutTooltip({ active, payload, currency }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0];
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg px-3 py-2 text-xs shadow-lg">
|
||||
<p className="font-semibold">{d.name}</p>
|
||||
<p className="text-muted-foreground">{formatCurrency(d.value, currency)}</p>
|
||||
<p className="text-muted-foreground">{d.payload.pct?.toFixed(1)}%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Donut: allocation by holding ─────────────────────────────────────────────
|
||||
|
||||
export function AllocationDonut({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const val = (h: HoldingResponse) => Number(h.current_value || h.cost_basis_total);
|
||||
const total = portfolio.holdings.reduce((s, h) => s + val(h), 0);
|
||||
const data = portfolio.holdings
|
||||
.filter((h) => val(h) > 0)
|
||||
.map((h, i) => ({
|
||||
name: h.symbol,
|
||||
value: val(h),
|
||||
pct: (val(h) / total) * 100,
|
||||
fill: COLORS[i % COLORS.length],
|
||||
}));
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Allocation by Holding</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="55%"
|
||||
outerRadius="80%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.name} fill={d.fill} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
|
||||
{data.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: d.fill }} />
|
||||
<span>{d.name}</span>
|
||||
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Donut: allocation by asset type ──────────────────────────────────────────
|
||||
|
||||
export function AssetTypeDonut({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const byType: Record<string, number> = {};
|
||||
for (const h of portfolio.holdings) {
|
||||
const t = (h.asset_type || "other").toLowerCase();
|
||||
byType[t] = (byType[t] ?? 0) + Number(h.current_value || h.cost_basis_total);
|
||||
}
|
||||
const total = Object.values(byType).reduce((s, v) => s + v, 0);
|
||||
const data = Object.entries(byType)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([type, value]) => ({
|
||||
name: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
value,
|
||||
pct: (value / total) * 100,
|
||||
fill: typeColor(type),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Allocation by Asset Type</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="55%"
|
||||
outerRadius="80%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.name} fill={d.fill} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
|
||||
{data.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: d.fill }} />
|
||||
<span>{d.name}</span>
|
||||
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bar: cost basis vs current value ─────────────────────────────────────────
|
||||
|
||||
function CurrencyYAxis({ value, currency }: { value: number; currency: string }) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
|
||||
return formatCurrency(value, currency);
|
||||
}
|
||||
|
||||
export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const data = portfolio.holdings
|
||||
.filter((h) => h.cost_basis_total > 0)
|
||||
.map((h) => ({
|
||||
symbol: h.symbol,
|
||||
cost: h.cost_basis_total,
|
||||
value: h.current_value ?? h.cost_basis_total,
|
||||
gain: (h.current_value ?? h.cost_basis_total) - h.cost_basis_total,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Cost Basis vs Current Value</p>
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, data.length * 52)}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="symbol"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => CurrencyYAxis({ value: v, currency: portfolio.currency })}
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
formatCurrency(value, portfolio.currency),
|
||||
name === "cost" ? "Cost basis" : "Current value",
|
||||
]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} />
|
||||
<Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}>
|
||||
{data.map((d) => (
|
||||
<Cell
|
||||
key={d.symbol}
|
||||
fill={d.gain >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-muted-foreground opacity-40" />
|
||||
Cost basis
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--success))" }} />
|
||||
Current value (gain)
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--destructive))" }} />
|
||||
Current value (loss)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bar: return % per holding ─────────────────────────────────────────────────
|
||||
|
||||
export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const data = portfolio.holdings
|
||||
.filter((h) => h.unrealised_gain_pct != null)
|
||||
.map((h) => ({
|
||||
symbol: h.symbol,
|
||||
pct: Number(h.unrealised_gain_pct),
|
||||
gain: h.unrealised_gain ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const absMax = Math.max(...data.map((d) => Math.abs(d.pct)), 5);
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Return % by Holding</p>
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 44)}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 48, left: 8, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[-absMax, absMax]}
|
||||
tickFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="symbol"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}>
|
||||
<LabelList
|
||||
dataKey="pct"
|
||||
position="right"
|
||||
formatter={(v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`}
|
||||
style={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
{data.map((d) => (
|
||||
<Cell
|
||||
key={d.symbol}
|
||||
fill={d.pct >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue