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
|
|
@ -90,6 +90,11 @@ backend/app/
|
||||||
- Soft deletes: `deleted_at` timestamp; all queries must filter `WHERE deleted_at IS NULL`
|
- Soft deletes: `deleted_at` timestamp; all queries must filter `WHERE deleted_at IS NULL`
|
||||||
- `_to_response()` in `transaction_service.py` must include all fields returned to the frontend — omitting a field here makes it invisible to the UI even if it's in the DB
|
- `_to_response()` in `transaction_service.py` must include all fields returned to the frontend — omitting a field here makes it invisible to the UI even if it's in the DB
|
||||||
|
|
||||||
|
### Investment data model
|
||||||
|
- Asset search uses `yf.Search(query)` (full-text, name or ticker) in `price_feed_service.search_yahoo()` — not ticker-only lookup
|
||||||
|
- `HoldingCreate.quantity` allows `ge=0` (zero) — holdings are created with quantity=0, and `add_investment_transaction()` drives the quantity via cumulative buy/sell updates. Never create a holding with the target quantity and also add a matching buy transaction, or the quantity doubles
|
||||||
|
- `PortfolioCharts.tsx` uses `||` (not `??`) when falling back from `current_value` to `cost_basis_total` — `??` fails when `current_value` is `0` rather than null
|
||||||
|
|
||||||
### AI / receipt parsing (`api/v1/settings.py`, `api/v1/transactions.py`)
|
### AI / receipt parsing (`api/v1/settings.py`, `api/v1/transactions.py`)
|
||||||
- User AI config (provider, encrypted API key, base URL, model, debug flag) lives on the `users` table; managed via `GET/PUT/DELETE /settings/ai`
|
- User AI config (provider, encrypted API key, base URL, model, debug flag) lives on the `users` table; managed via `GET/PUT/DELETE /settings/ai`
|
||||||
- `ai_api_key_enc` is AES-256-GCM encrypted with `encrypt_field`/`decrypt_field`
|
- `ai_api_key_enc` is AES-256-GCM encrypted with `encrypt_field`/`decrypt_field`
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,13 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w
|
||||||
|
|
||||||
### Investments
|
### Investments
|
||||||
- Portfolio tracking with holdings and buy/sell/dividend/split transactions
|
- Portfolio tracking with holdings and buy/sell/dividend/split transactions
|
||||||
|
- Asset search by name or ticker (stocks, ETFs, crypto, funds) via **yfinance** full-text search
|
||||||
- Live price feeds: stocks and ETFs via **yfinance**, crypto via **CoinGecko** (refreshed every 15 minutes)
|
- Live price feeds: stocks and ETFs via **yfinance**, crypto via **CoinGecko** (refreshed every 15 minutes)
|
||||||
- Hourly FX rates for multi-currency conversion to GBP base
|
- Hourly FX rates for multi-currency conversion to GBP base
|
||||||
- TWRR and MWRR performance metrics
|
- TWRR and MWRR performance metrics
|
||||||
- Capital gains reporting (short/long-term by tax year)
|
- Capital gains reporting (short/long-term by tax year)
|
||||||
- OHLCV candlestick charts per asset
|
- OHLCV candlestick charts per asset
|
||||||
- Allocation treemap across the full portfolio
|
- Portfolio visualisations: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar chart, return % per holding bar chart
|
||||||
|
|
||||||
### Reports
|
### Reports
|
||||||
Seven report views:
|
Seven report views:
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class AssetPricePoint(BaseModel):
|
||||||
class HoldingCreate(BaseModel):
|
class HoldingCreate(BaseModel):
|
||||||
account_id: uuid.UUID
|
account_id: uuid.UUID
|
||||||
asset_id: uuid.UUID
|
asset_id: uuid.UUID
|
||||||
quantity: Decimal = Field(..., gt=0)
|
quantity: Decimal = Field(..., ge=0)
|
||||||
avg_cost_basis: Decimal = Field(..., ge=0)
|
avg_cost_basis: Decimal = Field(..., ge=0)
|
||||||
currency: str = Field(default="GBP", min_length=3, max_length=10)
|
currency: str = Field(default="GBP", min_length=3, max_length=10)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,19 +98,26 @@ async def fetch_history(symbol: str, days: int = 365) -> list[dict]:
|
||||||
def search_yahoo(query: str) -> list[dict]:
|
def search_yahoo(query: str) -> list[dict]:
|
||||||
try:
|
try:
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
ticker = yf.Ticker(query)
|
results = yf.Search(query, max_results=10).quotes
|
||||||
info = ticker.fast_info
|
out = []
|
||||||
price = getattr(info, "last_price", None)
|
for r in results:
|
||||||
if price:
|
symbol = r.get("symbol")
|
||||||
return [{
|
if not symbol:
|
||||||
"symbol": query.upper(),
|
continue
|
||||||
"name": getattr(info, "long_name", None) or query.upper(),
|
quote_type = (r.get("quoteType") or r.get("typeDisp") or "stock").lower()
|
||||||
"type": "stock",
|
type_map = {"etf": "etf", "equity": "stock", "cryptocurrency": "crypto",
|
||||||
"currency": (getattr(info, "currency", None) or "USD").upper(),
|
"mutualfund": "fund", "bond": "bond"}
|
||||||
"exchange": getattr(info, "exchange", None),
|
asset_type = type_map.get(quote_type, "stock")
|
||||||
|
out.append({
|
||||||
|
"symbol": symbol,
|
||||||
|
"name": r.get("longname") or r.get("shortname") or symbol,
|
||||||
|
"type": asset_type,
|
||||||
|
"currency": "USD", # updated on first price sync
|
||||||
|
"exchange": r.get("exchDisp") or r.get("exchange"),
|
||||||
"data_source": "yahoo_finance",
|
"data_source": "yahoo_finance",
|
||||||
"data_source_id": None,
|
"data_source_id": None,
|
||||||
}]
|
})
|
||||||
|
return out
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
37
frontend/src/components/ErrorBoundary.tsx
Normal file
37
frontend/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props { children: ReactNode; }
|
||||||
|
interface State { error: Error | null; }
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-8 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-destructive/15 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-lg">Something went wrong</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 font-mono">{this.state.error.message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { this.setState({ error: null }); window.location.reload(); }}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Reload page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useUiStore } from "@/store/uiStore";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import TopBar from "./TopBar";
|
import TopBar from "./TopBar";
|
||||||
import MobileNav from "./MobileNav";
|
import MobileNav from "./MobileNav";
|
||||||
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -25,7 +26,7 @@ export default function AppShell({ children }: AppShellProps) {
|
||||||
<TopBar />
|
<TopBar />
|
||||||
{/* Extra bottom padding on mobile so content clears the nav bar */}
|
{/* Extra bottom padding on mobile so content clears the nav bar */}
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
|
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
|
||||||
{children}
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
["investment", "pension", "savings", "other"].includes(a.type)
|
["investment", "pension", "savings", "other"].includes(a.type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share");
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
account_id: investAccounts[0]?.id ?? "",
|
account_id: investAccounts[0]?.id ?? "",
|
||||||
quantity: "",
|
quantity: "",
|
||||||
|
|
@ -57,12 +58,13 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const qty = parseFloat(form.quantity);
|
const qty = parseFloat(form.quantity);
|
||||||
const price = parseFloat(form.price);
|
const rawPrice = parseFloat(form.price);
|
||||||
|
const price = priceMode === "total" ? rawPrice / qty : rawPrice;
|
||||||
const holding = await createHolding({
|
const holding = await createHolding({
|
||||||
account_id: form.account_id,
|
account_id: form.account_id,
|
||||||
asset_id: selected.id,
|
asset_id: selected.id,
|
||||||
quantity: qty,
|
quantity: 0,
|
||||||
avg_cost_basis: price,
|
avg_cost_basis: 0,
|
||||||
currency: form.currency,
|
currency: form.currency,
|
||||||
});
|
});
|
||||||
await addInvestmentTransaction({
|
await addInvestmentTransaction({
|
||||||
|
|
@ -76,7 +78,8 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
});
|
});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data?.detail ?? "Failed to add holding");
|
const detail = e?.response?.data?.detail;
|
||||||
|
setError(Array.isArray(detail) ? detail.map((d: any) => d.msg).join(", ") : (detail ?? "Failed to add holding"));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -159,8 +162,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
placeholder="10"
|
placeholder="10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="col-span-2">
|
||||||
<label className="text-xs font-medium block mb-1">Price paid *</label>
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{priceMode === "per_share" ? "Price per share *" : "Total price paid *"}
|
||||||
|
</label>
|
||||||
|
<div className="flex rounded-md border border-input overflow-hidden text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceMode("per_share")}
|
||||||
|
className={`px-2 py-0.5 transition-colors ${priceMode === "per_share" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
|
||||||
|
>
|
||||||
|
Per share
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceMode("total")}
|
||||||
|
className={`px-2 py-0.5 transition-colors ${priceMode === "total" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<select
|
<select
|
||||||
value={form.currency}
|
value={form.currency}
|
||||||
|
|
@ -174,9 +197,18 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
value={form.price}
|
value={form.price}
|
||||||
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
||||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
placeholder="150.00"
|
placeholder={priceMode === "per_share" ? "150.00" : "1500.00"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{form.price && form.quantity && parseFloat(form.quantity) > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{priceMode === "per_share"
|
||||||
|
? `Total: ${form.currency} ${(parseFloat(form.price) * parseFloat(form.quantity)).toFixed(2)}`
|
||||||
|
: `Per share: ${form.currency} ${(parseFloat(form.price) / parseFloat(form.quantity)).toFixed(4)}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium block mb-1">Fees</label>
|
<label className="text-xs font-medium block mb-1">Fees</label>
|
||||||
|
|
@ -188,7 +220,6 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{selected && form.currency !== selected.currency && (
|
{selected && form.currency !== selected.currency && (
|
||||||
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
|
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
|
||||||
|
|
@ -213,6 +244,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
|
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,12 +7,9 @@ import { formatCurrency } from "@/utils/currency";
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
|
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
|
||||||
import AddHoldingModal from "./AddHoldingModal";
|
import AddHoldingModal from "./AddHoldingModal";
|
||||||
|
import { AllocationDonut, AssetTypeDonut, CostVsValueChart, ReturnChart } from "./PortfolioCharts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
const COLORS = [
|
|
||||||
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
|
|
||||||
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
|
|
||||||
];
|
|
||||||
|
|
||||||
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
|
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
|
||||||
holding: HoldingResponse;
|
holding: HoldingResponse;
|
||||||
|
|
@ -156,14 +153,6 @@ export default function PortfolioPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const treemapData = portfolio?.holdings
|
|
||||||
.filter((h) => (h.current_value ?? h.cost_basis_total) > 0)
|
|
||||||
.map((h, i) => ({
|
|
||||||
name: h.symbol,
|
|
||||||
size: Number(h.current_value ?? h.cost_basis_total),
|
|
||||||
fill: COLORS[i % COLORS.length],
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -201,34 +190,21 @@ export default function PortfolioPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Allocation bar */}
|
{/* Charts */}
|
||||||
{treemapData.length > 1 && (
|
{portfolio && portfolio.holdings.length > 0 && (
|
||||||
<div className="bg-card border border-border rounded-xl p-4">
|
<>
|
||||||
<p className="text-sm font-medium mb-3">Allocation</p>
|
{/* Two donuts side by side */}
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{(() => {
|
<AllocationDonut portfolio={portfolio} />
|
||||||
const total = treemapData.reduce((s, d) => s + d.size, 0);
|
<AssetTypeDonut portfolio={portfolio} />
|
||||||
return treemapData.map((d, i) => (
|
|
||||||
<div
|
|
||||||
key={d.name}
|
|
||||||
style={{ width: `${Math.max(d.size / total * 100, 4)}%`, backgroundColor: COLORS[i % COLORS.length] }}
|
|
||||||
className="h-16 rounded flex items-center justify-center text-white text-xs font-bold overflow-hidden"
|
|
||||||
title={`${d.name}: ${formatCurrency(d.size, "GBP")}`}
|
|
||||||
>
|
|
||||||
{d.size / total > 0.06 ? d.name : ""}
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3 mt-3">
|
|
||||||
{treemapData.map((d, i) => (
|
|
||||||
<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: COLORS[i % COLORS.length] }} />
|
|
||||||
{d.name} — {formatCurrency(d.size, "GBP")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cost vs value */}
|
||||||
|
<CostVsValueChart portfolio={portfolio} />
|
||||||
|
|
||||||
|
{/* Return % per holding */}
|
||||||
|
<ReturnChart portfolio={portfolio} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Holdings table */}
|
{/* Holdings table */}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue