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:
megaproxy 2026-04-22 23:06:41 +00:00
parent 26e2a055db
commit cdc1e67321
9 changed files with 424 additions and 70 deletions

View 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>
);
}