diff --git a/website/TOC_IMPLEMENTATION_PLAN.md b/website/TOC_IMPLEMENTATION_PLAN.md index fa252b897..9ec40bebe 100644 --- a/website/TOC_IMPLEMENTATION_PLAN.md +++ b/website/TOC_IMPLEMENTATION_PLAN.md @@ -3,6 +3,7 @@ ## Übersicht Implementierung eines Docusaurus-ähnlichen Table of Contents für Blog-Posts mit: + - Sticky Sidebar rechts (Desktop >1200px) - Scroll-Spy (aktive Section hervorgehoben) - Versteckt auf Mobile/Tablet @@ -19,12 +20,14 @@ Implementierung eines Docusaurus-ähnlichen Table of Contents für Blog-Posts mi **Funktion:** Extrahiert Headings (h2, h3) aus dem DOM nach dem Render. **Anforderungen:** + - [ ] Akzeptiert `RefObject` für Content-Container - [ ] Generiert IDs für Headings ohne ID (slug-basiert) - [ ] Gibt Array von `{ id: string, text: string, level: number }` zurück - [ ] Re-extrahiert bei Content-Änderung **Abhängigkeiten:** + - Keine externen Pakete nötig --- @@ -36,12 +39,14 @@ Implementierung eines Docusaurus-ähnlichen Table of Contents für Blog-Posts mi **Funktion:** Scroll-Spy mit Intersection Observer API. **Anforderungen:** + - [ ] Akzeptiert Array von Heading-IDs - [ ] Verwendet `IntersectionObserver` mit `rootMargin: '-80px 0px -80% 0px'` - [ ] Gibt aktive Heading-ID zurück - [ ] Cleanup bei Unmount **Abhängigkeiten:** + - Keine externen Pakete nötig --- @@ -65,6 +70,7 @@ TableOfContents/ ### 2.2 `TableOfContents.tsx` **Anforderungen:** + - [ ] Props: `contentRef: RefObject`, `minHeadings?: number` (default: 2) - [ ] Verwendet `useTableOfContents` und `useActiveHeading` - [ ] Rendert nichts wenn `headings.length < minHeadings` @@ -76,6 +82,7 @@ TableOfContents/ ### 2.3 `TocItem.tsx` **Anforderungen:** + - [ ] Props: `heading: TocItem`, `isActive: boolean` - [ ] Indent für h3 (level 3) - [ ] Active-State Styling (linker Border + Farbe) @@ -86,6 +93,7 @@ TableOfContents/ ### 2.4 `styles.ts` (Panda CSS) **Anforderungen:** + - [ ] Container: `position: sticky`, `top: 80px`, `max-height: calc(100vh - 100px)` - [ ] Versteckt unter 1200px Breakpoint - [ ] Titel-Styling (klein, uppercase, muted) @@ -121,10 +129,11 @@ export interface TocItem { **Datei:** `website/layouts/styles.ts` (erweitern) **Neue Styles:** + - [ ] `articleLayout`: 3-Spalten Grid (`1fr minmax(0, 720px) 250px`) - [ ] `articleContent`: Mittlere Spalte - [ ] `articleSidebar`: Rechte Spalte (ToC) -- [ ] Responsive: +- [ ] Responsive: - `>1200px`: 3 Spalten - `768-1200px`: Content zentriert, kein ToC - `<768px`: Full-width @@ -136,12 +145,14 @@ export interface TocItem { **Datei:** `website/components/Post.tsx` **Änderungen:** + - [ ] `useRef` für Content-Container hinzufügen - [ ] Ref an `.e-content` Container übergeben - [ ] `` in Sidebar rendern - [ ] Layout-Wrapper mit neuem Grid **Struktur nach Änderung:** + ```tsx
@@ -171,11 +182,13 @@ export interface TocItem { **Problem:** Markdown-Headings brauchen IDs für Scroll-Navigation. -**Lösung A (empfohlen):** +**Lösung A (empfohlen):** + - [ ] In `useTableOfContents`: IDs zur Laufzeit generieren falls nicht vorhanden - [ ] Slug-Funktion: `text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')` **Alternative B:** + - [ ] rehype-slug Plugin in react-markdown integrieren (komplexer) --- @@ -219,38 +232,39 @@ export interface TocItem { ## Datei-Checkliste -| Datei | Aktion | Status | -|-------|--------|--------| -| `hooks/useTableOfContents.ts` | Neu erstellen | ✅ | -| `hooks/useActiveHeading.ts` | Neu erstellen | ✅ | -| `components/TableOfContents/index.ts` | Neu erstellen | ✅ | -| `components/TableOfContents/TableOfContents.tsx` | Neu erstellen | ✅ | -| `components/TableOfContents/TocItem.tsx` | Neu erstellen | ✅ | -| `components/TableOfContents/styles.ts` | Neu erstellen | ✅ | -| `types/components.ts` | Erweitern (TocItem) | ✅ (in useTableOfContents.ts) | -| `layouts/styles.ts` | Erweitern (articleLayout) | ✅ | -| `components/Post.tsx` | Refactoring (Grid + ToC) | ✅ | +| Datei | Aktion | Status | +| ------------------------------------------------ | ------------------------- | ----------------------------- | +| `hooks/useTableOfContents.ts` | Neu erstellen | ✅ | +| `hooks/useActiveHeading.ts` | Neu erstellen | ✅ | +| `components/TableOfContents/index.ts` | Neu erstellen | ✅ | +| `components/TableOfContents/TableOfContents.tsx` | Neu erstellen | ✅ | +| `components/TableOfContents/TocItem.tsx` | Neu erstellen | ✅ | +| `components/TableOfContents/styles.ts` | Neu erstellen | ✅ | +| `types/components.ts` | Erweitern (TocItem) | ✅ (in useTableOfContents.ts) | +| `layouts/styles.ts` | Erweitern (articleLayout) | ✅ | +| `components/Post.tsx` | Refactoring (Grid + ToC) | ✅ | --- ## Geschätzter Zeitaufwand -| Phase | Zeit | -|-------|------| -| Phase 1: Hooks | 45 min | -| Phase 2: Komponente | 60 min | -| Phase 3: Types | 10 min | -| Phase 4: Layout | 45 min | -| Phase 5: Heading-IDs | 20 min | -| Phase 6: Testing | 30 min | -| Phase 7: Feinschliff | 30 min | -| **Gesamt** | **~4 Stunden** | +| Phase | Zeit | +| -------------------- | -------------- | +| Phase 1: Hooks | 45 min | +| Phase 2: Komponente | 60 min | +| Phase 3: Types | 10 min | +| Phase 4: Layout | 45 min | +| Phase 5: Heading-IDs | 20 min | +| Phase 6: Testing | 30 min | +| Phase 7: Feinschliff | 30 min | +| **Gesamt** | **~4 Stunden** | --- ## Abhängigkeiten Keine neuen npm-Pakete erforderlich. Verwendet: + - React Hooks (useRef, useState, useEffect) - Intersection Observer API (nativ) - Panda CSS (bereits installiert) @@ -260,6 +274,7 @@ Keine neuen npm-Pakete erforderlich. Verwendet: ## Rollback-Plan Falls Probleme auftreten: + 1. `Post.tsx` auf vorherige Version zurücksetzen 2. ToC-Komponenten können ohne Side-Effects entfernt werden 3. Layout-Änderungen sind isoliert in neuen CSS-Klassen diff --git a/website/blog/etf_diversification_interactive.mdx b/website/blog/etf_diversification_interactive.mdx new file mode 100644 index 000000000..50d375e8d --- /dev/null +++ b/website/blog/etf_diversification_interactive.mdx @@ -0,0 +1,109 @@ +--- +title: "How to invest your money without predicting markets" +publishing_date: "2026-03-13" +tokenID: 192 +category: "others" +description: "A hands-on guide to ETF diversification using risk budgets, with interactive tools to find your own allocation." +--- + +import DiversificationRandomWalk from "../components/blog/DiversificationRandomWalk"; +import PortfolioRiskAllocator from "../components/blog/PortfolioRiskAllocator"; + +In this blog post, I present a simple, systematic approach to invest money across multiple asset classes. It is flexible enough to fit different risk tolerances and simple enough that it requires minimal time once set up. So it is pretty close to the approach that I use for my own savings. + +## The Diversification Paradox + +Once, you start to put money in the savings account, you might be tempted to look for better returns. And then you will typically have the choice between two +major investment classes. + +- [Bonds](https://en.wikipedia.org/wiki/Bond_(finance)): You lend money to a government or company. In return, you receive regular interest payments (called _coupons_) and get your principal back at a fixed date. They feel safe — steady, boring, predictable — but the returns are modest. +- [Stocks](https://en.wikipedia.org/wiki/Stock): You buy a small piece of a company. If the company grows, your share grows too; if it stumbles, so does your investment. They feel risky — volatile, unpredictable, scary — but historically they offer higher returns. + +If you are a risk averse first-time investor, the safe choice seems obvious — put everything into bonds. But try the simulator below first: drag the slider to mix bonds and stocks. Which one is the mix that fluctuates least? + + + +If you played with the slider, you probably noticed something counterintuitive. A mix (purple) fluctuates much less than 100% stocks (red) — but also less than 100% bonds (blue). When you combine assets that move independently, their random ups and downs partially cancel. The mix fluctuates less than any single part. + +Think of it like packing for unpredictable weather: an umbrella alone covers rain but not sunshine. Adding sunscreen doesn't make the bag heavier — it makes it useful for more situations. This principle is called **diversification**, and it's the closest thing to a free lunch in investing. + +## Why Chasing Returns Fails + +The simulator above already hinted at a fundamental trade-off: assets that offer higher returns tend to fluctuate more. Stocks historically earn more than bonds — but they also swing harder. This much is common sense. + +The trouble starts when you try to be precise about it. _How much_ extra return do stocks actually deliver? And is that enough to justify the extra risk? Once you try to answer these questions with numbers, you run into a wall. + +This kind of optimization earned [Harry Markowitz a Nobel Prize in 1990](https://www.nobelprize.org/prizes/economic-sciences/1990/summary/). Banks and fund +managers have tried his "mean-variance optimization" ever since. And if you tried it too, you would unfortunately discover +that this optimization **doesn't work in practice.** + +The reason is simple. To find the optimal mix, you need two ingredients: + +1. How much each asset _fluctuates_ (risk) — this we can measure reasonably well. +2. How much each asset will _earn in the future_ (expected return) — this we **cannot**. + +Estimating future returns from past data is like predicting next year's +weather from last year's diary. The mathematician Robert Merton showed in 1980 that you'd need +**80 to 100 years** of data to estimate stock returns with any confidence. + +The good news? We _can_ measure risk (volatility, correlations) much more +reliably. So instead of chasing the "best return", we focus on +the thing we _can_ control: **risk**. + +## How to actually buy "bonds" and "stocks" + +So far we've talked about bonds and stocks as abstract categories. But you can't walk into a shop and buy "some bonds". You need a concrete product — and this is where **ETFs** (Exchange-Traded Funds) come in. + +An [ETF](https://en.wikipedia.org/wiki/Exchange-traded_fund) is a fund that holds a basket of assets — hundreds or even thousands of individual bonds or stocks — and trades on a stock exchange like a single share. When you buy one share of an S&P 500 ETF, you instantly own a tiny slice of the 500 largest US companies. + +Why are ETFs so popular? + +- **Diversification in one click.** Instead of picking individual companies, you get the whole market (or a large chunk of it). +- **Low fees.** Because ETFs simply track an index rather than paying a manager to pick winners, annual costs are often below 0.2%. +- **Easy to trade.** You buy and sell them through any regular broker, just like a stock. Most European brokers even offer free monthly savings plans for popular ETFs. + +In the next section, we'll use 9 specific ETFs that cover bonds and stocks across different regions. You don't need to memorize them — the tool does the math. But it helps to know that each colored bar in the tool below represents a _group_ of ETFs, not a single company or government. + +## Allocating Risk Across Asset Classes + +Since we can measure risk reliably, we can use it as a building block: decide how much risk each asset group should contribute, and let the math determine the capital allocation. + +The tool below uses 9 European-listed ETFs grouped into three clusters based on how they move together: + +- 🟦 Bonds from European corporations and governments as well as governments in emerging markets. +- 🟥 Stocks for the US and the closely correlated Canadian market. +- 🟧 Stocks from Europe, Asia & Australia. + +Below you can set how much risk each group should bear — the tool computes the matching capital split. + + + +If you tried to use the tool, you might have noticed a few patterns: + +- **Bonds dominate capital, not risk.** Most presets allocate the majority of capital to bonds. That's not a mistake — bonds fluctuate so little that they need a lot of capital to "earn" their share of risk. +- **Even the safest portfolio holds stocks.** Try "Minimum risk": it's mostly bonds, but it still includes a small slice of equities. That small slice actually _lowers_ total risk — the same diversification effect you saw in the simulator above. +- **Your risk choice matters — a lot.** Compare "Minimum risk" to "Growth": annual volatility roughly quadruples. This isn't fine-tuning — it's the single most important decision in your savings plan. + +## Turning This Into a Monthly Savings Plan + +The tool above gives you a capital allocation — but this isn't about a single purchase. The idea is to set up an automatic monthly savings plan: + +1. **Pick a risk budget** that lets you sleep at night. "Equal risk" is a reasonable starting point for people that have a long time to sit out some swings. +2. **Split your monthly savings** according to the capital allocation the tool computes. For example, if you save €300/month and the tool says 60% bonds / 25% US stocks / 15% other stocks, that's €180 / €75 / €45. Most European brokers offer free ETF savings plans that execute automatically. + +The hardest part isn't choosing — it's sticking with it. The biggest risk is yourself: panic-selling during a crash or chasing last year's winner. A systematic monthly plan removes that temptation. + +This is close to what I do with my own savings. It doesn't require predicting markets, paying for advice, or watching charts. It just requires patience. + +--- + +## Technical notes + +This analysis uses daily EUR-denominated prices of the 9 iShares ETFs listed above, from 2018 to 2026. A few notes on methodology: + +- **Covariance estimation.** The covariance matrix is estimated using [Ledoit-Wolf shrinkage](https://en.wikipedia.org/wiki/Estimation_of_covariance_matrices#Shrinkage_estimation), which stabilizes the estimate when the number of assets is large relative to the number of observations. +- **Risk as volatility.** Risk is measured as annualized portfolio volatility (standard deviation of returns, scaled by √252 trading days). This is a simplification — it treats upside and downside swings equally — but it is the standard starting point for portfolio construction. +- **Cluster assignment.** The three asset groups (Bonds, US & Canada, EU/Asia/Australia) were derived using Ward-linkage hierarchical clustering on bootstrapped correlation matrices. One manual adjustment: 3SUD (EM Bonds) was moved from the "Regional Diversifiers" cluster to the Bonds cluster, since it is easier to explain a bond in the bond group. +- **Risk budgeting.** Capital allocation follows the risk budgeting framework of [Roncalli (2013)](https://doi.org/10.2139/ssrn.2272862). Each asset within a cluster receives an equal share of that cluster's risk budget, and the solver finds weights such that actual risk contributions match the targets. + +_The full analysis notebooks are not published yet, but can be made available on request._ diff --git a/website/blog/multichain_technical_notes.md b/website/blog/multichain_technical_notes.md index f42c2f1c6..abd41f6c2 100644 --- a/website/blog/multichain_technical_notes.md +++ b/website/blog/multichain_technical_notes.md @@ -34,6 +34,7 @@ const chain = getViemChain(network); ``` **Why CAIP-2?** + - Human-readable (`eip155:10` vs `10`) - Standard across wallets, indexers, block explorers - Type-safe with TypeScript (can't accidentally pass chainId where network expected) @@ -59,6 +60,7 @@ monorepo/ ### 1. Shared Package: `@fretchen/chain-utils` **Exports:** + - `toCAIP2(chainId)` / `fromCAIP2(network)` - conversion utilities - `getViemChain(network)` - returns viem Chain object - `getGenAiNFTAddress(network)` - contract address lookup @@ -67,9 +69,10 @@ monorepo/ - Contract ABIs **Critical detail:** No `prepare` script. CI must explicitly build before consumers install: + ```yaml - run: cd shared/chain-utils && npm ci && npm run build -- run: cd website && npm ci # Now chain-utils is built +- run: cd website && npm ci # Now chain-utils is built ``` ### 2. Backend: Network Parameter @@ -87,12 +90,14 @@ const network = body.network; // "eip155:10" | "eip155:8453" | ... ### 3. Frontend: Multi-Chain Hooks **New hook: `useMultiChainNFTs`** + ```typescript const { tokens, isLoading, reload } = useMultiChainUserNFTs(); // Returns NFTs from ALL supported chains, merged ``` **New component: `ChainBadge`** + ```typescript // Renders "Base" badge ``` @@ -109,6 +114,7 @@ const { tokens, isLoading, reload } = useMultiChainUserNFTs(); ``` The `network` parameter flows through the entire stack: + - Frontend → Backend → Payment Facilitator → Smart Contract ## Testing Architecture (Key Learning) @@ -148,6 +154,7 @@ it("should fail gracefully with invalid config", async () => { ``` **Why this matters:** + - Functional tests are 10x faster → run on every save - Deployment tests catch CI/CD issues → run before deploy - Clear separation prevents "test pollution" (ethers global state affecting viem tests) @@ -185,14 +192,14 @@ vi.mock("../hooks/useMultiChainNFTs", () => ({ ## Metrics -| Metric | Value | -|--------|-------| -| Planning duration | ~4 weeks | +| Metric | Value | +| ----------------------- | ---------- | +| Planning duration | ~4 weeks | | Implementation duration | ~3-4 weeks | -| Lines changed | ~2,500 | -| New tests | ~50 | -| Test coverage | >90% | -| Breaking changes | 0 | +| Lines changed | ~2,500 | +| New tests | ~50 | +| Test coverage | >90% | +| Breaking changes | 0 | ## Common Pitfalls @@ -201,6 +208,7 @@ vi.mock("../hooks/useMultiChainNFTs", () => ({ 2. **Nonce race conditions:** Parallel requests can cause "nonce too low" errors. Viem auto-manages nonces, but add retry logic for resilience. 3. **Type casting with wagmi:** `chainId` from `fromCAIP2()` returns `number`, but wagmi expects specific chain IDs: + ```typescript const chainId = fromCAIP2(network) as SupportedChainId; ``` @@ -212,4 +220,4 @@ vi.mock("../hooks/useMultiChainNFTs", () => ({ - [CAIP-2 Specification](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) - [Viem Multi-Chain Guide](https://viem.sh/docs/clients/chains.html) - [ERC-8004 (Agent Authorization)](https://eips.ethereum.org/EIPS/eip-8004) -- [Implementation Proposal](/website/MULTICHAIN_EXPANSION_PROPOSAL.md) \ No newline at end of file +- [Implementation Proposal](/website/MULTICHAIN_EXPANSION_PROPOSAL.md) diff --git a/website/components/blog/DiversificationRandomWalk.tsx b/website/components/blog/DiversificationRandomWalk.tsx new file mode 100644 index 000000000..0c2aba974 --- /dev/null +++ b/website/components/blog/DiversificationRandomWalk.tsx @@ -0,0 +1,558 @@ +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { Line } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import { css } from "../../styled-system/css"; +import { DATA } from "./etfData"; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); + +// ============================================================ +// Random Walk helpers +// ============================================================ +const gaussianRandom = (): number => { + let u = 0; + let v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); +}; + +const TRADING_DAYS_PER_YEAR = 252; +const STEPS = 504; // 2 trading years — more points for smoother histogram +const ANIM_BATCH = 8; // steps drawn per frame +const ANIM_INTERVAL = 25; // ms between frames + +interface Paths { + bondReturns: number[]; + stockReturns: number[]; +} + +/** Generate correlated daily returns for bonds and stocks. + * Uses Cholesky decomposition for the 2×2 case. */ +function generatePaths(sigBond: number, sigStock: number, rho: number): Paths { + const dailySigBond = sigBond / Math.sqrt(TRADING_DAYS_PER_YEAR); + const dailySigStock = sigStock / Math.sqrt(TRADING_DAYS_PER_YEAR); + const bondReturns: number[] = []; + const stockReturns: number[] = []; + for (let i = 0; i < STEPS; i++) { + const z1 = gaussianRandom(); + const z2 = gaussianRandom(); + bondReturns.push(dailySigBond * z1); + stockReturns.push(dailySigStock * (rho * z1 + Math.sqrt(1 - rho * rho) * z2)); + } + return { bondReturns, stockReturns }; +} + +/** Compute cumulative price path starting at 100. */ +function cumulativePath(dailyReturns: number[], steps: number): number[] { + const path = [100]; + for (let i = 0; i < steps; i++) { + path.push(path[i] * (1 + dailyReturns[i])); + } + return path; +} + +/** Annualized volatility measured from daily returns. */ +function measuredVol(dailyReturns: number[], steps: number): number { + const slice = dailyReturns.slice(0, steps); + const mean = slice.reduce((a, b) => a + b, 0) / slice.length; + const variance = slice.reduce((s, r) => s + (r - mean) ** 2, 0) / slice.length; + return Math.sqrt(variance * TRADING_DAYS_PER_YEAR) * 100; // percent, annualized +} + +// ============================================================ +// Live-filling SVG Histogram (vertical / rotated — sits beside the line chart) +// ============================================================ +const HIST_BINS = 25; +const HIST_RANGE = 0.025; // ±2.5% daily return range + +interface HistogramProps { + bondReturns: number[]; + stockReturns: number[]; + mixReturns: number[]; + visibleSteps: number; +} + +function ReturnHistogram({ bondReturns, stockReturns, mixReturns, visibleSteps }: HistogramProps) { + const width = 120; + const height = 300; // matches the line-chart height + const margin = { top: 6, right: 4, bottom: 6, left: 4 }; + const plotW = width - margin.left - margin.right; + const plotH = height - margin.top - margin.bottom; + const binHeight = plotH / HIST_BINS; + const binSize = (2 * HIST_RANGE) / HIST_BINS; + + const toBins = (returns: number[], steps: number): number[] => { + const bins = new Array(HIST_BINS).fill(0); + for (let i = 0; i < steps; i++) { + const idx = Math.floor((returns[i] + HIST_RANGE) / binSize); + if (idx >= 0 && idx < HIST_BINS) bins[idx]++; + } + return bins; + }; + + const bondBins = toBins(bondReturns, visibleSteps); + const stockBins = toBins(stockReturns, visibleSteps); + const mixBins = toBins(mixReturns, visibleSteps); + const maxCount = Math.max(1, ...bondBins, ...stockBins, ...mixBins); + + // Render order: stocks (back), bonds, mix (front) + const series = [ + { bins: stockBins, color: DATA.clusters[1].color, opacity: 0.35, label: "Stocks" }, + { bins: bondBins, color: DATA.clusters[0].color, opacity: 0.45, label: "Bonds" }, + { bins: mixBins, color: "#7b3fa0", opacity: 0.6, label: "Mix" }, + ]; + + // Y-axis ticks (return percentages — bottom=negative, top=positive to match price chart) + const ticks = [-2, -1, 0, 1, 2].map((pct) => ({ + pct, + // bin 0 = most negative, bin N-1 = most positive + // invert so positive is at top + y: margin.top + plotH - ((pct / 100 + HIST_RANGE) / (2 * HIST_RANGE)) * plotH, + })); + + return ( + + {/* Bars grow leftward from right edge */} + {series.map((s) => + s.bins.map((count, i) => { + const barW = (count / maxCount) * plotW; + // Invert: bin 0 (most negative) at bottom, bin N-1 (most positive) at top + const y = margin.top + plotH - (i + 1) * binHeight; + return ( + + ); + }), + )} + + {/* Zero line */} + {ticks + .filter((t) => t.pct === 0) + .map((t) => ( + + ))} + + {/* Y-axis tick labels (return %) */} + {ticks.map((t) => ( + + {t.pct > 0 ? "+" : ""} + {t.pct}% + + ))} + + ); +} + +// ============================================================ +// DiversificationRandomWalk component +// ============================================================ +// Pedagogical parameters — strongly exaggerated for visual clarity +// (real data: σ_bond=4.6%, σ_stock=20.1%, ρ=+0.17) +// With these values, σ_mix ≈ 6% at 50/50 — clearly less than either alone +const PEDAGOGY = { + sigBond: 0.15, // 15% — inflated so bonds are clearly visible + sigStock: 0.2, // 20% + rho: -0.8, // strongly anti-correlated — mix cancels most fluctuation +}; + +export default function DiversificationRandomWalk() { + const [stockPct, setStockPct] = useState(50); + const [hasMovedSlider, setHasMovedSlider] = useState(false); + const [paths, setPaths] = useState(null); + const [visibleSteps, setVisibleSteps] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const animRef = useRef | null>(null); + + const sigBond = PEDAGOGY.sigBond; + const sigStock = PEDAGOGY.sigStock; + const rho = PEDAGOGY.rho; + + const stopAnimation = useCallback(() => { + if (animRef.current) { + clearInterval(animRef.current); + animRef.current = null; + } + setIsAnimating(false); + }, []); + + // Cleanup on unmount + useEffect(() => stopAnimation, [stopAnimation]); + + // Auto-start animation on mount + useEffect(() => { + const fresh = generatePaths(sigBond, sigStock, rho); + setPaths(fresh); + setVisibleSteps(0); + setIsAnimating(true); + let step = 0; + animRef.current = setInterval(() => { + step = Math.min(step + ANIM_BATCH, STEPS); + setVisibleSteps(step); + if (step >= STEPS) { + clearInterval(animRef.current!); + animRef.current = null; + setIsAnimating(false); + } + }, ANIM_INTERVAL); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startAnimation = useCallback( + (newPaths?: Paths) => { + stopAnimation(); + let p = newPaths ?? paths; + if (!p) { + p = generatePaths(sigBond, sigStock, rho); + setPaths(p); + } + setVisibleSteps(0); + setIsAnimating(true); + let step = 0; + animRef.current = setInterval(() => { + step = Math.min(step + ANIM_BATCH, STEPS); + setVisibleSteps(step); + if (step >= STEPS) { + clearInterval(animRef.current!); + animRef.current = null; + setIsAnimating(false); + } + }, ANIM_INTERVAL); + }, + [paths, sigBond, sigStock, rho, stopAnimation], + ); + + const handleNewPaths = useCallback(() => { + const fresh = generatePaths(sigBond, sigStock, rho); + setPaths(fresh); + startAnimation(fresh); + }, [sigBond, sigStock, rho, startAnimation]); + + // Derived data for chart + const w = stockPct / 100; + const stepsToShow = paths ? visibleSteps : 0; + const labels = Array.from({ length: stepsToShow + 1 }, (_, i) => { + if (i === 0) return "Start"; + if (i === STEPS) return "24 months"; + const month = Math.floor((i / STEPS) * 24); + return month > 0 && i % Math.round(STEPS / 24) === 0 ? `${month} mo` : ""; + }); + + const bondPath = paths ? cumulativePath(paths.bondReturns, stepsToShow) : []; + const stockPath = paths ? cumulativePath(paths.stockReturns, stepsToShow) : []; + const mixReturns = paths ? paths.bondReturns.map((br, i) => (1 - w) * br + w * paths.stockReturns[i]) : []; + const mixPath = paths ? cumulativePath(mixReturns, stepsToShow) : []; + + const chartData = { + labels, + datasets: [ + { + label: "Bonds only", + data: bondPath, + borderColor: DATA.clusters[0].color, + backgroundColor: "transparent", + borderWidth: 2, + pointRadius: 0, + pointStyle: "line" as const, + tension: 0.1, + }, + { + label: "Stocks only", + data: stockPath, + borderColor: DATA.clusters[1].color, + backgroundColor: "transparent", + borderWidth: 2, + pointRadius: 0, + pointStyle: "line" as const, + tension: 0.1, + }, + { + label: `Mix (${100 - stockPct}/${stockPct})`, + data: mixPath, + borderColor: "#7b3fa0", + backgroundColor: "transparent", + borderWidth: 3, + pointRadius: 0, + pointStyle: "line" as const, + tension: 0.1, + }, + ], + }; + + const chartOptions = { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 0 }, + plugins: { + legend: { position: "top" as const, labels: { font: { size: 12 }, usePointStyle: true, pointStyleWidth: 20 } }, + title: { display: false }, + }, + scales: { + x: { + title: { display: true, text: "Months", font: { size: 11 } }, + ticks: { + maxTicksLimit: 13, + font: { size: 10 }, + callback: function (_: unknown, index: number) { + return labels[index] || null; + }, + }, + }, + y: { + title: { + display: true, + text: "Portfolio value (€)", + font: { size: 11 }, + }, + ticks: { font: { size: 10 } }, + suggestedMin: 70, + suggestedMax: 130, + }, + }, + }; + + // Measured volatilities (only meaningful when animation finished) + const showVol = stepsToShow >= STEPS && paths; + const bondVol = showVol ? measuredVol(paths.bondReturns, STEPS) : null; + const stockVol = showVol ? measuredVol(paths.stockReturns, STEPS) : null; + const mixVol = showVol ? measuredVol(mixReturns, STEPS) : null; + + return ( +
+

+ How does mixing bonds and stocks look in practice? +

+

+ Each line shows a possible two-year journey of €100. Parameters are exaggerated for clarity (bonds and stocks + move in opposite directions here). Watch how the mix (purple) is smoother than either alone. +

+ + {/* Slider */} +
+ +
+ 100% Bonds + 100% Stocks +
+ { + setStockPct(parseInt(e.target.value, 10)); + setHasMovedSlider(true); + }} + className={css({ width: "100%" })} + /> +
+ + {/* Button */} +
+ + {showVol && !hasMovedSlider && ( + + 👆 Move the slider to see how the mix changes + + )} +
+ + {/* Chart + side histogram */} +
+ {/* Line chart */} +
+ +
+ + {/* Rotated histogram on the right */} + {paths && stepsToShow > 0 && ( +
+ + Daily return spread + +
+ +
+
+ )} +
+ + {/* Annotation */} + {paths && stepsToShow > 0 && ( +

+ Left: cumulative portfolio value over time. Right: distribution of daily returns — narrower means less + fluctuation. Notice how the purple mix is narrower than both bonds and stocks individually. +

+ )} + + {/* Volatility badges */} + {showVol && ( +
+

+ How bumpy was this ride? (lower = smoother) +

+
+ {[ + { + label: "Bonds", + vol: bondVol!, + color: DATA.clusters[0].color, + }, + { + label: "Stocks", + vol: stockVol!, + color: DATA.clusters[1].color, + }, + { label: "Mix", vol: mixVol!, color: "#7b3fa0" }, + ].map((item) => ( + + + {item.label}: {item.vol.toFixed(1)}% + + ))} +
+ {mixVol! < bondVol! && ( +

+ 👉 The mix fluctuates {((1 - mixVol! / bondVol!) * 100).toFixed(0)}% less than bonds alone — adding stocks + actually reduced risk! +

+ )} +
+ )} +
+ ); +} diff --git a/website/components/blog/PortfolioRiskAllocator.tsx b/website/components/blog/PortfolioRiskAllocator.tsx new file mode 100644 index 000000000..f3e29858d --- /dev/null +++ b/website/components/blog/PortfolioRiskAllocator.tsx @@ -0,0 +1,606 @@ +import React, { useState, useMemo, useRef, useEffect } from "react"; +import { css } from "../../styled-system/css"; +import { DATA } from "./etfData"; + +const N_ETF = DATA.etf_names.length; +const ETF_INDEX = new Map(DATA.etf_names.map((name, i) => [name, i])); + +/** Matrix-vector product: Σw */ +function matVec(cov: number[][], w: number[]): number[] { + const n = w.length; + const result = new Array(n).fill(0); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + result[i] += cov[i][j] * w[j]; + } + } + return result; +} + +/** Solve risk-budget allocation via Roncalli (2013) multiplicative update. + * Per-asset ERC within each cluster: b_i = B_c / n_c (matching Python NB07). */ +function solveRiskBudget(cov: number[][], clusterOf: number[], budgets: number[]): number[] { + const n = cov.length; + const nC = budgets.length; + const active = new Array(n).fill(true); + for (let i = 0; i < n; i++) if (budgets[clusterOf[i]] < 1e-10) active[i] = false; + const activeCount = active.filter(Boolean).length; + if (activeCount === 0) return new Array(n).fill(1 / n); + + // Per-asset risk budgets: b_i = B_c / clusterSize_c + const clusterSize = new Array(nC).fill(0); + for (let i = 0; i < n; i++) if (active[i]) clusterSize[clusterOf[i]]++; + const targetRc = new Array(n).fill(0); + for (let i = 0; i < n; i++) if (active[i]) targetRc[i] = budgets[clusterOf[i]] / clusterSize[clusterOf[i]]; + + // Initialize: w_i ∝ b_i / σ_i (inverse-vol scaled by budget) + let w = new Array(n).fill(0); + for (let i = 0; i < n; i++) if (active[i]) w[i] = targetRc[i] / Math.sqrt(cov[i][i]); + const wInit = w.reduce((a, b) => a + b, 0); + if (wInit > 0) w = w.map((wi) => wi / wInit); + + for (let iter = 0; iter < 5000; iter++) { + const sw = matVec(cov, w); + const rc = w.map((wi, i) => wi * sw[i]); + const totalRc = rc.reduce((a, b) => a + b, 0); + if (totalRc < 1e-15) break; + + // Convergence: per-asset risk contribution vs target + let maxErr = 0; + for (let i = 0; i < n; i++) if (active[i]) maxErr = Math.max(maxErr, Math.abs(rc[i] / totalRc - targetRc[i])); + if (maxErr < 1e-8) break; + + // Multiplicative update per asset + for (let i = 0; i < n; i++) { + if (!active[i]) continue; + const rcFrac = rc[i] / totalRc; + if (rcFrac < 1e-15) continue; + w[i] *= Math.pow(targetRc[i] / rcFrac, 0.5); + } + const wSum = w.reduce((a, b) => a + b, 0); + if (wSum > 0) w = w.map((wi) => wi / wSum); + } + return w; +} + +/** Solve long-only minimum variance: min w'Σw s.t. Σw=1, w≥0. */ +function solveMinVariance(cov: number[][]): number[] { + const n = cov.length; + let w = new Array(n).fill(1 / n); + for (let iter = 0; iter < 5000; iter++) { + const grad = matVec(cov, w).map((g) => 2 * g); + const raw = w.map((wi, i) => wi - 0.5 * grad[i]); + const sorted = [...raw].sort((a, b) => b - a); + let cumSum = 0; + let rho = 0; + for (let j = 0; j < n; j++) { + cumSum += sorted[j]; + if (sorted[j] - (cumSum - 1) / (j + 1) > 0) rho = j; + } + const theta = (sorted.slice(0, rho + 1).reduce((a, b) => a + b, 0) - 1) / (rho + 1); + w = raw.map((r) => Math.max(0, r - theta)); + } + return w; +} + +const N_CLUSTERS = DATA.clusters.length; + +/** Compute portfolio risk metrics from fractional weights */ +function computeRisk(w: number[]) { + const sigmaW = matVec(DATA.cov_etf_ann, w); + const portVar = w.reduce((s, wi, i) => s + wi * sigmaW[i], 0); + const portVol = Math.sqrt(Math.max(0, portVar)) * 100; // annualized % + + // Risk contributions (% of total variance) + const rc = w.map((wi, i) => (portVar > 0 ? ((wi * sigmaW[i]) / portVar) * 100 : 0)); + + // Cluster-level aggregation + const clusterWeight = new Array(N_CLUSTERS).fill(0); + const clusterRc = new Array(N_CLUSTERS).fill(0); + for (let i = 0; i < N_ETF; i++) { + const ci = DATA.etf_cluster[i]; + clusterWeight[ci] += w[i] * 100; + clusterRc[ci] += rc[i]; + } + + return { w, portVol, rc, clusterWeight, clusterRc }; +} + +// Precompute minimum-variance risk budgets for preset +const _mvW = solveMinVariance(DATA.cov_etf_ann); +const _mvRisk0 = computeRisk(_mvW); +const MIN_VOL = _mvRisk0.portVol; +const MAX_VOL = Math.max(...DATA.vol_cluster_ann) * 100; + +/** Interpolate color from calm blue to warm red based on position in [MIN_VOL, MAX_VOL]. */ +function bumpinessColor(vol: number): string { + const t = Math.max(0, Math.min(1, (vol - MIN_VOL) / (MAX_VOL - MIN_VOL))); + // #4e79a7 (blue) → #e15759 (red) + const r = Math.round(78 + t * (225 - 78)); + const g = Math.round(121 + t * (87 - 121)); + const b = Math.round(167 + t * (89 - 167)); + return `rgb(${r},${g},${b})`; +} +const _mvRisk = _mvRisk0.clusterRc.map((v) => Math.round(v)); +_mvRisk[_mvRisk.indexOf(Math.max(..._mvRisk))] += 100 - _mvRisk.reduce((a, b) => a + b, 0); + +const PRESETS: { label: string; description: string; budgets: number[] }[] = [ + { + label: "Just two", + description: "Only bonds and US stocks contribute risk", + budgets: [50, 50, 0], + }, + { + label: "Equal risk", + description: "Each group contributes equally — a balanced starting point", + budgets: [33, 33, 34], + }, + { + label: "Minimum risk", + description: "The lowest-risk portfolio — heavily bond-dominated", + budgets: _mvRisk, + }, + { + label: "Growth", + description: "Most risk from stocks, small bond cushion", + budgets: [10, 45, 45], + }, +]; + +export default function PortfolioRiskAllocator() { + const [budgets, setBudgets] = useState([...PRESETS[1].budgets]); + const [activePreset, setActivePreset] = useState(1); + const [expandedCluster, setExpandedCluster] = useState(null); + const [dragging, setDragging] = useState(null); + const barRef = useRef(null); + + const { weights, risk } = useMemo(() => { + const w = solveRiskBudget( + DATA.cov_etf_ann, + DATA.etf_cluster, + budgets.map((b) => b / 100), + ); + return { weights: w, risk: computeRisk(w) }; + }, [budgets]); + + const applyPreset = (i: number) => { + setBudgets([...PRESETS[i].budgets]); + setActivePreset(i); + }; + + useEffect(() => { + if (dragging === null) return; + const onMove = (e: PointerEvent) => { + if (!barRef.current) return; + const rect = barRef.current.getBoundingClientRect(); + const pos = Math.round(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * 100); + setBudgets((prev) => { + const next = [...prev]; + if (dragging === 0) { + const b2 = next[0] + next[1]; + next[0] = Math.max(0, Math.min(b2, pos)); + next[1] = b2 - next[0]; + } else { + const clamped = Math.max(next[0], Math.min(100, pos)); + next[1] = clamped - next[0]; + next[2] = 100 - clamped; + } + return next; + }); + setActivePreset(-1); + }; + const onUp = () => setDragging(null); + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + return () => { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + }; + }, [dragging]); + + return ( +
+ {/* ── Header ── */} +

+ Build your portfolio by risk budget +

+

+ Set how much risk each group should contribute — the math finds the capital allocation. +

+ + {/* ── Preset buttons ── */} +
+
+ {PRESETS.map((p, i) => ( + + ))} +
+ {activePreset >= 0 && ( +

+ {PRESETS[activePreset].description} +

+ )} +
+ + {/* ── Draggable risk-budget bar ── */} +
+
+ Refine your mix — drag the ● circles to adjust +
+ {/* Percentage labels above the track */} +
+ {DATA.clusters.map((cluster, ci) => ( +
+ {budgets[ci] >= 6 ? `${budgets[ci]}%` : ""} +
+ ))} +
+ {/* Thin track with round thumb circles */} +
+ {/* Colored track segments (clipped inside their own wrapper) */} +
+ {DATA.clusters.map((cluster, ci) => ( +
+ ))} +
+ {/* Round thumb handles */} + {[0, 1].map((hi) => { + const leftPct = budgets.slice(0, hi + 1).reduce((a, b) => a + b, 0); + if (leftPct <= 0 || leftPct >= 100) return null; + return ( +
{ + e.preventDefault(); + setDragging(hi); + }} + style={{ + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + left: `calc(${leftPct}% - 14px)`, + width: "28px", + height: "28px", + cursor: "col-resize", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 10, + }} + > +
+
+ ); + })} +
+ {/* Legend */} +
+ {DATA.clusters.map((cluster) => ( +
+ + {cluster.name} +
+ ))} +
+
+ + {/* ── Bumpiness gauge (between drag bar and results) ── */} +
+
+ How bumpy is this portfolio? + + {risk.portVol.toFixed(1)}% + + per year +
+
+
+
+
+ smoothest mix ({MIN_VOL.toFixed(1)}%) + single stock group ({MAX_VOL.toFixed(1)}%) +
+
+ + {/* ── Portfolio summary ── */} +
+ {/* Capital allocation bar */} +
+
+ Capital allocation (computed) +
+
+ {DATA.clusters.map((cluster, ci) => { + const pct = risk.clusterWeight[ci]; + return pct > 0 ? ( +
+ {pct >= 8 ? `${pct.toFixed(0)}%` : ""} +
+ ) : null; + })} +
+
+ + {/* ── Cluster detail cards ── */} +
+ {DATA.clusters.map((cluster, ci) => { + const isOpen = expandedCluster === ci; + return ( +
+ + + {isOpen && ( +
+ + + + + + + + + + + {cluster.etfs.map((etf) => { + const idx = ETF_INDEX.get(etf)!; + const etfVol = Math.sqrt(DATA.cov_etf_ann[idx][idx]) * 100; + return ( + + + + + + + ); + })} + +
+ ETF + + Weight + + Vol (ann.) + + Risk contrib. +
+ {DATA.etf_labels[idx]}{" "} + + {etf} + + + {(weights[idx] * 100).toFixed(1)}% + + {etfVol.toFixed(1)}% + + {risk.rc[idx].toFixed(1)}% +
+
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/website/components/blog/etfData.ts b/website/components/blog/etfData.ts new file mode 100644 index 000000000..b0e0e52b4 --- /dev/null +++ b/website/components/blog/etfData.ts @@ -0,0 +1,85 @@ +// ============================================================ +// DATA PAYLOAD — extracted from NB02 & NB07 +// 12 ETFs across 3 clusters (Ward-Linkage, k=3) +// All prices converted to EUR, annualized (252 trading days) +// ============================================================ +export const DATA = { + clusters: [ + { name: "Bonds", etfs: ["IBCS", "EUNH", "3SUD"], color: "#4e79a7" }, + { + name: "Stocks: US & Canada", + etfs: ["SXR8", "CSCA"], + color: "#e15759", + }, + { + name: "Stocks: EU, Asia & Australia", + etfs: ["SXRT", "NDIA", "SAUS", "CSJP"], + color: "#f28e2b", + }, + ], + // 3×3 annualized covariance matrix (cluster-level equal-weighted returns) + cov_cluster_ann: [ + [0.00223955, 0.00109166, 0.00127042], + [0.00109166, 0.03516473, 0.01496456], + [0.00127042, 0.01496456, 0.01637447], + ], + vol_cluster_ann: [0.047324, 0.187523, 0.127963], + corr_cluster: [ + [1.0, 0.123, 0.2098], + [0.123, 1.0, 0.6236], + [0.2098, 0.6236, 1.0], + ], + crises: { + "Corona 2020": { drawdowns: [-0.0663, -0.3836, -0.3355] }, + "Ukraine 2022": { drawdowns: [-0.1681, -0.1192, -0.1299] }, + }, + return_uncertainty: { + premium_ann: [-0.017068, 0.082034, 0.054618], + se_ann: [0.018455, 0.07313, 0.049903], + }, + part1_example: { + vol_100_bonds: 0.0473, + vol_80_20: 0.0565, + vol_50_50: 0.0995, + vol_100_stocks: 0.1875, + corr_bonds_stocks: 0.123, + }, + + // 9×9 Ledoit-Wolf annualized covariance matrix (9-ETF subset, removing EXXY, IQQ6, 4BRZ) + // Order: IBCS, EUNH, SXR8, SXRT, 3SUD, NDIA, SAUS, CSJP, CSCA + etf_names: ["IBCS", "EUNH", "SXR8", "SXRT", "3SUD", "NDIA", "SAUS", "CSJP", "CSCA"], + etf_cluster: [0, 0, 1, 2, 0, 2, 2, 2, 1], // index into clusters[] + etf_labels: [ + "EU Corporate Bonds", + "EU Government Bonds", + "S&P 500", + "Euro Stoxx 50", + "EM Bonds", + "India", + "Australia", + "Japan", + "Canada", + ], + etf_urls: [ + "https://www.ishares.com/uk/individual/en/products/251565/ishares-euro-corporate-bond-large-cap-ucits-etf", + "https://www.ishares.com/ch/professionals/en/products/251740/ishares-core-euro-government-bond-ucits-etf", + "https://www.ishares.com/ch/professionals/en/products/253743/ishares-core-sp-500-ucits-etf", + "https://www.ishares.com/ch/professionals/en/products/251781/ishares-core-euro-stoxx-50-ucits-etf-de", + "https://www.ishares.com/uk/individual/en/products/308633/ishares-j-p-morgan-em-bond-ucits-etf", + "https://www.ishares.com/ch/professionals/en/products/297617/", + "https://www.ishares.com/ch/professionals/en/products/251851/ishares-msci-australia-ucits-etf", + "https://www.ishares.com/ch/professionals/en/products/253732/ishares-msci-japan-b-ucits-etf-acc-fund", + "https://www.ishares.com/ch/professionals/en/products/253721/ishares-msci-canada-b-ucits-etf", + ], + cov_etf_ann: [ + [0.00235705, 0.00188179, 0.00101898, 0.00116085, 0.00153483, 0.0012564, 0.00201358, 0.00177889, 0.00117574], + [0.00188179, 0.00414109, 0.00102919, 0.00027942, 0.00172953, 0.0003092, 0.00087098, 0.00145604, 0.00091051], + [0.00101898, 0.00102919, 0.05870897, 0.01795636, 0.01371741, 0.01500817, 0.0169256, 0.00912919, 0.04577796], + [0.00116085, 0.00027942, 0.01795636, 0.03857897, 0.00564821, 0.01384506, 0.0194126, 0.00793152, 0.02163545], + [0.00153483, 0.00172953, 0.01371741, 0.00564821, 0.00944084, 0.0080297, 0.00815381, 0.00644341, 0.01361257], + [0.0012564, 0.0003092, 0.01500817, 0.01384506, 0.0080297, 0.03787152, 0.01661183, 0.01099622, 0.01826119], + [0.00201358, 0.00087098, 0.0169256, 0.0194126, 0.00815381, 0.01661183, 0.04185897, 0.02076469, 0.02349577], + [0.00177889, 0.00145604, 0.00912919, 0.00793152, 0.00644341, 0.01099622, 0.02076469, 0.0396158, 0.01126801], + [0.00117574, 0.00091051, 0.04577796, 0.02163545, 0.01361257, 0.01826119, 0.02349577, 0.01126801, 0.05142616], + ], +};