diff --git a/CLAUDE.md b/CLAUDE.md index 599de25..3aacebb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,11 @@ backend/app/ - 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 +### 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`) - 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` diff --git a/README.md b/README.md index 7632e44..2ff0885 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,13 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w ### Investments - 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) - Hourly FX rates for multi-currency conversion to GBP base - TWRR and MWRR performance metrics - Capital gains reporting (short/long-term by tax year) - 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 Seven report views: diff --git a/backend/app/schemas/investment.py b/backend/app/schemas/investment.py index e4d0e07..7db6bc1 100644 --- a/backend/app/schemas/investment.py +++ b/backend/app/schemas/investment.py @@ -36,7 +36,7 @@ class AssetPricePoint(BaseModel): class HoldingCreate(BaseModel): account_id: uuid.UUID asset_id: uuid.UUID - quantity: Decimal = Field(..., gt=0) + quantity: Decimal = Field(..., ge=0) avg_cost_basis: Decimal = Field(..., ge=0) currency: str = Field(default="GBP", min_length=3, max_length=10) diff --git a/backend/app/services/price_feed_service.py b/backend/app/services/price_feed_service.py index c26473d..161aa13 100644 --- a/backend/app/services/price_feed_service.py +++ b/backend/app/services/price_feed_service.py @@ -98,19 +98,26 @@ async def fetch_history(symbol: str, days: int = 365) -> list[dict]: def search_yahoo(query: str) -> list[dict]: try: import yfinance as yf - ticker = yf.Ticker(query) - info = ticker.fast_info - price = getattr(info, "last_price", None) - if price: - return [{ - "symbol": query.upper(), - "name": getattr(info, "long_name", None) or query.upper(), - "type": "stock", - "currency": (getattr(info, "currency", None) or "USD").upper(), - "exchange": getattr(info, "exchange", None), + results = yf.Search(query, max_results=10).quotes + out = [] + for r in results: + symbol = r.get("symbol") + if not symbol: + continue + quote_type = (r.get("quoteType") or r.get("typeDisp") or "stock").lower() + type_map = {"etf": "etf", "equity": "stock", "cryptocurrency": "crypto", + "mutualfund": "fund", "bond": "bond"} + 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_id": None, - }] + }) + return out except Exception: pass return [] diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..0231407 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + render() { + if (this.state.error) { + return ( +
+
+ +
+
+

Something went wrong

+

{this.state.error.message}

+
+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index e0c8122..93befa2 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -2,6 +2,7 @@ import { useUiStore } from "@/store/uiStore"; import Sidebar from "./Sidebar"; import TopBar from "./TopBar"; import MobileNav from "./MobileNav"; +import ErrorBoundary from "@/components/ErrorBoundary"; interface AppShellProps { children: React.ReactNode; @@ -25,7 +26,7 @@ export default function AppShell({ children }: AppShellProps) { {/* Extra bottom padding on mobile so content clears the nav bar */}
- {children} + {children}
diff --git a/frontend/src/pages/investments/AddHoldingModal.tsx b/frontend/src/pages/investments/AddHoldingModal.tsx index b5baff9..e187c43 100644 --- a/frontend/src/pages/investments/AddHoldingModal.tsx +++ b/frontend/src/pages/investments/AddHoldingModal.tsx @@ -27,6 +27,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) ["investment", "pension", "savings", "other"].includes(a.type) ); + const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share"); const [form, setForm] = useState({ account_id: investAccounts[0]?.id ?? "", quantity: "", @@ -57,12 +58,13 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) setError(null); try { 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({ account_id: form.account_id, asset_id: selected.id, - quantity: qty, - avg_cost_basis: price, + quantity: 0, + avg_cost_basis: 0, currency: form.currency, }); await addInvestmentTransaction({ @@ -76,7 +78,8 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) }); onSuccess(); } 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 { setSaving(false); } @@ -159,8 +162,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) placeholder="10" /> -
- +
+
+ +
+ + +
+
setForm(f => ({ ...f, fees: 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" - placeholder="0" - /> -
+
+
+ + setForm(f => ({ ...f, fees: 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" + placeholder="0" + />
{selected && form.currency !== selected.currency && ( @@ -213,6 +244,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) Cancel