MoneyView — Building Numbers People Can Trust
This is the build story behind MoneyView — why it started small, how it grew into something much bigger than I planned, and the one lesson from building it that I’d tell anyone building a tool meant to inform real decisions.
Why I Started This
MoneyView began as a simple idea: a Python project to track my own personal assets and spending. Nothing ambitious — just a place to see where my money was going, built in a language I already knew well. The kind of project you start because the existing apps don’t quite fit how you think about your own finances.
How It Actually Evolved
“Track my spending” turned out to be the easy 10% of the idea. The interesting questions were all one level deeper — not what did my portfolio do, but why. That pull toward “why” is what turned MoneyView from a tracker into a local-first quantitative analysis platform with four real pillars:
-
Market Overview — a landing dashboard summarizing global indices, commodities, currencies, and crypto, so I have context before diving into any one position: is the market carrying everything up, or is something I did actually working?
-
Portfolio Command Center — real Brinson-Fachler performance attribution, splitting excess return into three named effects instead of one vague “you beat the market” number:
Allocation = (wP − wB) × (RB,sector − RB,total) Selection = wB × (RP,sector − RB,sector) Interaction = (wP − wB) × (RP,sector − RB,sector)The difference between “Tesla went up” and “I overweighted tech and that’s what paid off” is the difference between luck and a repeatable process, and that distinction needed real math, not a vibe.
-
Corporate Analysis — a DCF intrinsic-value engine built on a small stack of formulas that all feed into each other:
Cost of Equity (Ke) = Rf + β × ERP + CRP WACC = (E / V) × Ke + (D / V) × Kd × (1 − Tax Rate) FCFF = EBIT × (1 − Tax Rate) + D&A − CapEx − ΔNWC— risk-free rate, beta-scaled equity risk premium, and a country risk premium roll up into a discount rate; free cash flow to firm gets projected forward and folded into a Gordon Growth terminal value. For thinly-traded names, beta itself gets rebuilt bottom-up through the Hamada equation — unlever a peer group’s betas, then relever them to the target company’s own capital structure and tax rate. The question all of it answers: is this company actually creating value above its cost of capital (
ROIC > WACC), or is the market just excited?Underneath all of that math, the engine is really just chasing down one practical checklist — call it the “perfect company” trifecta-plus-one: (1) does it earn more than its cost of capital (
ROIC > WACC), (2) is its actual debt below the level that would minimize its WACC, (3) is its free cash flow to equity larger than what it actually pays out, and (4) does the model say it’s worth more than the market is charging for it? Four yes/no questions stand in for “is this a good business at a fair price.”The trickiest part of that whole stack, by far, is the terminal value — the assumption that a company keeps growing at some steady rate
gforever after the explicit forecast window ends. Two things make it dangerous: the math breaks outright ifgis allowed to exceed the risk-free rate (a company can’t grow faster than the economy forever without absurd implications), and in practice the terminal value alone often accounts for 70–100% of the entire valuation. Get that one number wrong by a percentage point, and the “intrinsic value” the model reports moves far more than any of the company’s actual fundamentals justify — which is exactly the kind of fragile, assumption-driven number that taught me the lesson in the next section. -
Monte Carlo / Simulation Lab — instead of one “answer,” tens of thousands of simulated future paths from a stochastic process that takes crashes seriously instead of assuming markets are calm:
dS = S × (μ dt + σ dW) + S × J dNgeometric Brownian motion (the smooth
μ dt + σ dWdrift-and-noise term) plus a jump-diffusion term (J dN) for the sudden shocks that fat-tailed real markets actually produce. When multiple assets are simulated together, Cholesky decomposition factors their correlation matrix so the random shocks driving each path stay correlated the way the real assets are — a portfolio of two stocks that move together shouldn’t simulate as if they’re independent.
None of this was the plan on day one. It came from chasing the next “but why?” until the answers needed their own engine.
Growth like that can easily turn into spaghetti, so the architecture grew its own discipline
alongside the features. MoneyView stayed deliberately local-first — Next.js talking to a FastAPI
backend, backed by SQLite and a pure Python/NumPy core_finance package, with Pydantic models as the
single schema source of truth and generated TypeScript contracts keeping the frontend honest. The
rule that mattered most: financial formulas live in core_finance and nowhere else — not in API
routes, not in frontend components. That one constraint did more to keep four growing analysis tabs
from tangling into each other than any amount of after-the-fact refactoring could have. Likewise,
heavier infrastructure — Docker, Postgres, Redis, Rust — stayed firmly in the “later, if benchmarks
prove it’s needed” column instead of being adopted because it sounded more serious. Most of the
read/write paths stayed simple request-scoped calculations on purpose; a persisted “snapshot” read
model only got introduced for the one place — corporate comparison — where the same derived numbers
were genuinely being recomputed from unchanged data over and over.
The Lesson That Taught Me the Most
Of everything MoneyView taught me, one lesson outranks the rest, and it had nothing to do with finance theory: a number without a “why” isn’t information — it’s a liability.
Early on, metrics like ROIC, WACC, and DCF upside could render in the UI as a bare N/A, or worse,
as a plausible-looking value that was actually built on missing or unstable inputs. On a dashboard,
that’s just noise. On a tool whose entire purpose is to help someone make a real financial decision,
it’s actively dangerous — a confident-looking number that quietly isn’t trustworthy is worse than no
number at all, because it doesn’t look like a problem.
One bug made that risk concrete instead of theoretical. On the Corporate Analysis page, source data
was cached per session to avoid re-fetching expensive lookups. But after a browser refresh, the page
could restore MSFT as the active company while the cache still held AAPL’s source-data payload —
and nothing checked that the cached snapshot’s ticker actually matched the ticker on screen. Refresh
while looking at Apple, switch to Microsoft, refresh again, and you could be looking at Microsoft’s
name with Apple’s numbers quietly filled in underneath. The data wasn’t wrong in isolation — it was
wrong for the company currently on screen, which is a much harder kind of wrong to notice. The fix
was to make every cached payload carry its own ticker identity, check it against the active ticker
before using it anywhere — controls, calculation modals, exports — and show an explicit
Cached source data is for AAPL. Refresh for MSFT. message when they didn’t match. Old data could
still exist, but the page would no longer pretend it was current.
The fix wasn’t a UI patch. It was rebuilding how the platform talks about its own confidence:
- Explicit confidence states for every metric —
ok,estimated,stale,suspicious,invalid,missing— instead of a single undifferentiated value or blank. - Reason-bearing fallback copy.
N/AbecameN/A — Missing ROIC input. A suspicious value renders asROIC: 124.2% [Suspicious]with a link to why, instead of being styled like a normal healthy metric. - Audit modals with real lineage. Click into any computed number and see the formula that produced it, the raw inputs, the intermediate values (NOPAT, invested capital, beginning/ending balances), the data source, and the calculation version — not a black box, a worked solution.
The lesson generalizes past finance: if a tool’s job is to help someone decide something that matters, hiding uncertainty doesn’t make the tool look more confident — it makes the tool less trustworthy the moment someone actually checks. Surfacing “I don’t know, and here’s exactly why” is a feature, not an admission of weakness.
What I’d Tell Past Me
The Monte Carlo probability cones and the DCF radar charts are what get noticed first — they’re the
parts that look like a “real” quant tool. But the audit modals, the confidence badges, the
reason-bearing N/A states — those are what make the rest of it worth trusting. Nobody opens an app
and says “wow, great error messaging.” But that’s exactly the unglamorous work that turns a demo
that computes numbers into a tool someone could actually rely on to make a decision. Past me
would have shipped the chart first. Present me knows the chart is only as good as the trust behind
the number it’s drawing.
Architecture & Conventions
This is the technical onboarding reference for probationer070.github.io —
read this before making non-trivial changes so you don’t have to re-derive the
project’s structure and conventions from scratch. For a plain-language
description of what the site is, see overview.md.
Stack & Build
| Concern | Choice |
|---|---|
| Framework | Astro 6.4.4 (static site generation, file-based routing) |
| Language | TypeScript, content authored in Markdown/MDX (@astrojs/mdx) |
| Syntax highlighting | Shiki, configured for dual themes (github-light / github-dark), switched via [data-theme] CSS variables — both themes are baked into the build, so theme toggling needs no re-render |
| Animation | GSAP |
| Images | Sharp (limitInputPixels: false to handle large source images) |
| Contact form | EmailJS (emailjs-com) |
| SEO / syndication | @astrojs/sitemap, src/pages/rss.xml.js (RSS via @astrojs/rss + fast-xml-parser) |
| Output | Static build to dist/, deployed via GitHub Pages |
Scripts: npm run dev / build / preview / astro (standard Astro CLI).
Directory Map
| Path | What lives here |
|---|---|
src/pages/ | File-based routes — see Routing table below |
src/components/ | BaseHead, Header, Footer, FormattedDate, HeaderLink |
src/layouts/ | BlogPost.astro (general post/page layout), StudyPost.astro (study-post layout with TOC) |
src/content/ | Content collections — study/ (~20 posts), project/ (4 posts); schemas in src/content.config.ts |
src/content.config.ts | Zod schema definitions for the study and project collections — the source of truth for frontmatter shape |
src/consts.ts | Site-wide constants: SITE_TITLE, SITE_DESCRIPTION |
src/styles/ | Global and per-feature CSS — see Styling below |
src/scripts/ | Client-side TS utilities, e.g. toc.ts (table-of-contents generator) |
src/assets/ | Images grouped by content type: study/, project/, design_sample/ |
docs/ | Project documentation — changelog, error log, design specs (see Documentation Conventions) |
public/, fonts/ | Static assets and custom fonts |
Content Model
Both collections are loaded via glob from src/content/{project,study} and
schema-validated with Zod in src/content.config.ts. Frontmatter that doesn’t
match the schema fails the build — this is the project’s main correctness net
for content.
study posts — title, description, pubDate, optional
updatedDate/heroImage, and tags via either tags: string[] or
tag: string[] (both accepted, a deliberate accommodation for inconsistent
historical frontmatter — see the Korean comment in content.config.ts).
project posts — everything study has, plus:
status: 'shipped' | 'in-progress' | 'planned'— drives the status badgerepoUrl/liveUrl— external links (validated as URLs)tech: string[]— tech-stack chipsseries: string+order: number— groups related posts into an ordered build-log series (e.g. the four Cloud Project entries)
When adding a post: drop a .md/.mdx file into src/content/study/ or
src/content/project/, fill in frontmatter per the schema above, and place any
hero image under the matching src/assets/{study,project}/ folder.
Routing
| Route | File | Notes |
|---|---|---|
/ | src/pages/index.astro | Hero, horizontal project rail, contact |
/study | src/pages/study.astro | Isometric 3D card deck of all study posts |
/study/[slug] | src/pages/study/[...slug].astro | Individual study post (via StudyPost layout) |
/study/tag/[tag] | src/pages/study/tag/[...tag].astro | Dynamic catch-all — generates one page per unique tag, collected from both tags and tag fields across all study posts |
/project | src/pages/project.astro | Project showcase grid/rail |
/project/[slug] | src/pages/project/[...slug].astro | Individual project post |
/about | src/pages/about.astro | Profile page (aboutBak.astro is a backup/previous version, not routed) |
/rss.xml | src/pages/rss.xml.js | Generated RSS feed |
Dynamic routes follow Astro’s [...param] catch-all convention — the tag route
is the canonical example of building getStaticPaths() from a deduplicated set
gathered across a whole collection.
Styling
CSS is split by scope rather than using a single global stylesheet:
global.css— base/shared styles and theme variablesstudy.css,study-editorial.css— the 3D card deck and study-post reading viewcyber.css,taggedlist.css— feature-specific visual treatmentsLayouts/— per-page styles (Header.css,Footer.css,index.css,Main.css,project.css,about.css,blogPost.css)
When changing a page’s look, find its dedicated stylesheet first rather than
editing global.css.
Notable Implementation Patterns
- Dual-theme syntax highlighting: Shiki generates both light and dark
highlighted output at build time; CSS variables scoped to
[data-theme]switch which one is visible, avoiding any re-highlight flash on toggle. - Isometric 3D study-card deck (
study.astro+study.css/study-editorial.css): CSS 3D transforms position cards in an isometric belt; GSAP drives the hover “splay” animation; arrow keys navigate the deck; tag filtering re-queries the deduplicated tag set described in the routing table above. - Scroll-aware header (
Header.astro,onHeaderScroll): hides/shows the nav based on scroll direction. This has bitten the project before — seeERR-002below — because Astro’s view-transition navigation can leave the cached.header-navreference stale across page loads. - Generated table of contents (
src/scripts/toc.ts): scans a configurableproseSelectorfor headings and builds a nav list, used byStudyPostandBlogPostlayouts. - Custom horizontal scroll: the home project rail and tag-detail pages convert vertical mouse-wheel input into horizontal scrolling via hand-written listeners (no library).
Documentation Conventions
This project keeps structured records of its own history — read the relevant folder before starting similar work, and add to it the same way:
| Location | Convention |
|---|---|
docs/changelog/ | One file per significant change, named YY-MM-DD [type] Subject.md (type ∈ test/upgrade/bug). Required fields: Type, Files Changed, Why Changed, Contents Diff, Improvements, Performance Impact, Agents Consulted, Findings Addressed, Findings Deferred. Indexed in docs/changelog/README.md. |
docs/error/ | One file per confirmed bug, named ERR-NNN-short-description.md (symptom, root cause, fix, prevention, test added). Indexed in docs/error/README.md. Existing entries: ERR-001 (elementFromPoint timing), ERR-002 (header scroll-hide stale ref), ERR-003 (study tag double-init readyState). |
docs/superpowers/specs/ & docs/superpowers/plans/ | Dated design docs and their corresponding implementation plans, e.g. 2026-06-07-post-nav-code-toc-design.md / ...-toc.md. |
docs/design/ | Standalone feature design notes that don’t fit the spec/plan pairing, e.g. study-scrub-timeline.md. |
.claude/CLAUDE.md | Behavioral guidelines for AI agents working in this repo — read first for the project’s working conventions (think-before-coding, surgical changes, change logging, error recording). |
Where To Look First
| Task | Start here |
|---|---|
| Add or edit a study/project post | src/content/{study,project}/, frontmatter shape in src/content.config.ts |
| Change study-card visuals or animation | src/styles/study.css, src/styles/study-editorial.css, src/pages/study.astro |
| Change header behavior (scroll-hide, nav links) | src/components/Header.astro, src/components/HeaderLink.astro — check ERR-002 first |
| Adjust table-of-contents behavior | src/scripts/toc.ts, src/layouts/{StudyPost,BlogPost}.astro |
| Add/adjust syntax-highlight theming | Shiki config (Astro config) and [data-theme] rules in global.css |
| Site-wide title/description | src/consts.ts |
| Tag filtering or tag pages | src/pages/study/tag/[...tag].astro (collects tags from both tags and tag frontmatter fields) |