Let's Talk

Feel free to reach out. I'll get back to you as soon as possible.

MoneyView — Building Numbers People Can Trust

IN PROGRESS Python · TypeScript · FastAPI · Next.js GitHub ↗

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 g forever after the explicit forecast window ends. Two things make it dangerous: the math breaks outright if g is 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 dN

    geometric Brownian motion (the smooth μ dt + σ dW drift-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/A became N/A — Missing ROIC input. A suspicious value renders as ROIC: 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

ConcernChoice
FrameworkAstro 6.4.4 (static site generation, file-based routing)
LanguageTypeScript, content authored in Markdown/MDX (@astrojs/mdx)
Syntax highlightingShiki, 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
AnimationGSAP
ImagesSharp (limitInputPixels: false to handle large source images)
Contact formEmailJS (emailjs-com)
SEO / syndication@astrojs/sitemap, src/pages/rss.xml.js (RSS via @astrojs/rss + fast-xml-parser)
OutputStatic build to dist/, deployed via GitHub Pages

Scripts: npm run dev / build / preview / astro (standard Astro CLI).

Directory Map

PathWhat 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.tsZod schema definitions for the study and project collections — the source of truth for frontmatter shape
src/consts.tsSite-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 poststitle, 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 badge
  • repoUrl / liveUrl — external links (validated as URLs)
  • tech: string[] — tech-stack chips
  • series: 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

RouteFileNotes
/src/pages/index.astroHero, horizontal project rail, contact
/studysrc/pages/study.astroIsometric 3D card deck of all study posts
/study/[slug]src/pages/study/[...slug].astroIndividual study post (via StudyPost layout)
/study/tag/[tag]src/pages/study/tag/[...tag].astroDynamic catch-all — generates one page per unique tag, collected from both tags and tag fields across all study posts
/projectsrc/pages/project.astroProject showcase grid/rail
/project/[slug]src/pages/project/[...slug].astroIndividual project post
/aboutsrc/pages/about.astroProfile page (aboutBak.astro is a backup/previous version, not routed)
/rss.xmlsrc/pages/rss.xml.jsGenerated 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 variables
  • study.css, study-editorial.css — the 3D card deck and study-post reading view
  • cyber.css, taggedlist.css — feature-specific visual treatments
  • Layouts/ — 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 — see ERR-002 below — because Astro’s view-transition navigation can leave the cached .header-nav reference stale across page loads.
  • Generated table of contents (src/scripts/toc.ts): scans a configurable proseSelector for headings and builds a nav list, used by StudyPost and BlogPost layouts.
  • 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:

LocationConvention
docs/changelog/One file per significant change, named YY-MM-DD [type] Subject.md (typetest/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.mdBehavioral 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

TaskStart here
Add or edit a study/project postsrc/content/{study,project}/, frontmatter shape in src/content.config.ts
Change study-card visuals or animationsrc/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 behaviorsrc/scripts/toc.ts, src/layouts/{StudyPost,BlogPost}.astro
Add/adjust syntax-highlight themingShiki config (Astro config) and [data-theme] rules in global.css
Site-wide title/descriptionsrc/consts.ts
Tag filtering or tag pagessrc/pages/study/tag/[...tag].astro (collects tags from both tags and tag frontmatter fields)