Skip to main content
Leaving The Matrix
nova-dev 7 min read

DRIP for Nova Fund

Dividend reinvestment shipped for the Nova Fund portfolio simulator, plus a SPY benchmark that walks the same DRIP path. The smoke test caught a timestamp bug worth keeping a note about.

#nova#finance#war-story

The blog hasn't talked about Nova Fund yet. Quick context: it's a real portfolio simulator inside Nova that takes weekly deposits, picks names off the screener, and tracks them as a real ledger you can replay. It's separate from the screener and Lookup — those are research surfaces; Nova Fund is the "actually live with the score" surface. Inception is May 10. Until then everything ships against synthetic data.

Today's slice was Phase 5 prereq #3: DRIP. Dividend reinvestment, plus a SPY benchmark that walks the same path. The smoke test caught a date bug along the way.

Why DRIP matters

Without reinvested dividends, a portfolio simulator holding dividend-paying names systematically under-reports return. Worse, if you're benchmarking against SPY without DRIP-ing the benchmark too, the comparison is tilted — SPY pays dividends in real life, and not crediting them on the benchmark side makes any simulator look better than it is.

Apples to apples means: if Nova reinvests its dividends, the SPY benchmark must reinvest its dividends. Otherwise the alpha number is fake.

The data source

Yahoo's events=div chart endpoint. Same provider as OHLC, free, no new API key. Per-symbol disk cache with a 24-hour TTL at {vault}/Screener/cache/dividends/. We already trust Yahoo for daily bars; trusting it for dividend history is just consistency. The endpoint returns ex-date and dividend amount per share, which is exactly what DRIP needs.

The ledger model

New entry kind "dividend" with cashDelta = 0 (immediately reinvested). The position.shares field grows by the reinvested-shares count; position.costBasisTotal grows by the reinvested-cash amount. State mutates in place.

Worth flagging the naming choice: kind is "dividend", not "drip". The ledger records economic events; DRIP is what the simulator does with them. If we ever want a no-DRIP variant later, the data model already supports it — same dividend events, different downstream behavior. Naming the kind after the policy would have locked us into one policy.

Idempotency

Dedupe on (symbol, ex-date). Re-running syncDividends is safe; running it twice doesn't double-credit. This matters because executeWeeklyRun calls it every Sunday morning — we don't want to re-credit AAPL's August 2024 dividend every week from now until forever.

The smoke verified it: first run recorded 8 AAPL dividends across two synthetic years; second run added zero entries.

The SPY benchmark

New simulateSpyDrip in analytics.ts. Interleaves deposits and SPY ex-dividends in time order, walks forward accumulating shares as both happen. Same logic Nova Fund uses for its own DRIP, applied to a single benchmark holding.

The benchmark needs the same temporal honesty Nova Fund does: a deposit on day N buys SPY at day N's price; a SPY dividend on day M reinvests at day M's price; deposits and dividends interleave according to their actual dates rather than batching. Otherwise the benchmark drifts from a simple "what would buying SPY have done" answer.

The smoke test setup

Real Nova Fund is empty pre-inception, so I built a temp-vault script:

  • Init fund on 2024-05-04.
  • Three $1,000 deposits spread across two years.
  • Buy five AAPL at $170 on inception (held flat for the whole run).
  • Run syncDividends, then re-run it (idempotency check).
  • Run simulateSpyDrip across the same two years.

Synthetic, but the right shape: deposits, a long-held position, dividends to reinvest, and a benchmark to compare against.

The timestamp that lied

Then the bug.

sharesHeldAt(entries, t) is supposed to return the share count held at time t. Used to walk a ledger forward and answer "as of this date, how many shares did the fund hold?" The implementation used e.at directly — the timestamp on each ledger entry — to decide whether the entry was "before" or "after" t.

The problem: applyBuy stamps e.at = now() when it creates the entry, regardless of e.decisionDate. For a fresh buy "right now," those two are the same and nothing is wrong. For a backdated buy — say, recording inception on 2024-05-04 from a script running in 2026 — at is 2026 and decisionDate is 2024. The journal-ordering field was telling the truth (it's when we wrote the entry). The "occurrence" question got the wrong answer.

DRIP made it worse. DRIP entries carry historical ex-dates as at, because the entry's reason for existing is the dividend's ex-date, not the moment we synced it. So an AAPL dividend from August 2024 has at = 2024-08-15 — correct economically, but it broke the assumption baked into sharesHeldAt that at was always "wall-clock at insertion time."

You had two timestamps that meant different things, the function was using the wrong one, and the answer happened to look plausible because the synthetic SPY return curve was monotone enough to mask small drift. The smoke test caught it because the AAPL dividend for August 2024 reinvested at a share count that wasn't yet five (the buy hadn't "occurred" yet by the broken time logic).

The fix: min(at, decisionDate) for replay-time questions. at still wins for journal ordering — that's its job. Replay uses min(at, decisionDate) because that's the actual moment the economic event occurred. Fixed in dividends.ts:sharesHeldAt and analytics.ts:ledgerEntryTime.

The pattern, banked: when a system has a creation timestamp and an event-occurrence timestamp, replay logic should use whichever is the truthful occurrence. at for ordering is fine; replay needs the right "when did this happen." The bug stays hidden until you have either backdated entries or events whose at is intentionally historical — both of which DRIP introduced for the first time.

Final smoke results

Nova final value $3,007.68 against $3,000 deposited (held five shares of AAPL flat at $170, plus 0.045 reinvested-dividend shares from $10.24 of cash). SPY benchmark: $3,716.19 final value, with $41.85 of dividends reinvested over the two years. Alpha: -23.6%, exactly what should show up when you hold AAPL flat for two years while SPY runs +20%. Synthetic, but the math agrees.

Typecheck clean across all 17 packages. Smoke-test scripts deleted after verification. The DRIP plumbing is on the working tree pending review and commit; not pushed yet.

What this unblocks

Phase 5 of the portfolio-product arc had three prereqs: ledger correctness, the weekly-run engine, and dividend reinvestment. Ledger correctness shipped first; weekly-run shipped second; DRIP is the third. With it landed, Nova Fund's inception on May 10 has a complete simulator behind it — deposits, position tracking, weekly-run scheduling, dividends, benchmark.

The next prereqs ahead of the broader Phase 5 work: deploying Nova web to Amplify under nova.leavingthematrix.io, and a desktop-to-web data sync (Sunday cron pushes the local vault state into a Postgres mirror so the web view sees the same numbers the desktop does). Both gating issues are practical, not architectural.

Then May 10 inception. Real deposits, real dividends, real SPY comparison. The simulator stops being synthetic.

Free Your Mind · Free weekly newsletter

Liked this? Get the next one in your inbox.

One full ticker through the framework + the lesson behind it, every week. Unsubscribe in one click.

Want the full picture?

Smart-money flow, real conversations, the whole framework.

Leave the Matrix
Leave the Matrix