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

Same ticker, two scores

Spot-checking RF surfaced a 4-point gap between Scanner and Lookup. Three PRs to make scoring universal, then a long debug chain to make the math actually agree. Two load-bearing bugs in there.

#nova#screener#war-story#architecture

RF scored 70.7 in Swing Confluence and 66 on Lookup. MSFT scored 79 on Scanner and 81 on Lookup. Same ticker, same scoring system, different numbers. The post that wraps up Phase 8 closed with a consistency audit queued for "after Phase 12." That schedule didn't survive the day. Here's what came out of moving it up.

The first gap: layers (PR #51)

Lookup uses explainSymbol(), which always fetches all twelve layers. Scanner and AI Picks use runScan(), which only fetches a layer when its preset weight is greater than zero. The 7.7-arc layerstrack_record, support, fund_quality — were wired into presets.ts with non-zero weights. They were never wired into runScan's conditional-fetch convention. Same ticker, two paths, different inputs.

The fix wired all three into the existing fetch pattern, gated on preset weight greater than zero, fetched only for the top-60 enrichSet, reusing the 1-hour TTL caches. The support layer also got upgraded to use the same 10-year bars Lookup uses (vs the 1-year Scanner had), so its full 200-week-MA reading fires in both paths.

Banked: when adding a new score layer, the audit checklist must include both presets.ts and runScan, not just explainSymbol. The 7.7 arc got into Lookup three times and missed runScan three times.

The second gap: language (PR #52)

After #51 the math agreed across paths, but the LLM was still describing a 5-6 layer view. Agent system prompt didn't enumerate the new layers. Screener skill manifest didn't either. AI Picks' LLM prompt named the wrong subset. The save_preset schema only exposed weight fields for five layers — so chat couldn't even create a preset that touched track_record, support, or fund_quality.

PR #52 fixed all four narrative surfaces in one shot. The agent prompt now enumerates twelve layers grouped by family. Skill manifest names the twelve. AI Picks' LLM prompt names the twelve and sends per-layer signal summaries in the body so the model can actually reference layer reads in narrative. save_preset exposes weights for all twelve. buildTemplateThesis got three new clauses for the missing layers.

Banked: when a new score layer ships, the audit checklist must include the narrative surfaces (system prompt, skill descriptions, AI Picks prompt, deterministic template thesis). Otherwise the LLM describes a score it doesn't actually see.

The architectural shift: PR #53

Spot-checking RF surfaced a deeper problem. RF scored 70.7 in Swing Confluence and 66 on Lookup not because of a layer gap (#51 had fixed that) but because the weights were different. Every preset had its own weight scheme. Same ticker, two presets, two scores by design.

Luke's framing is the right one: "the score is always supposed to be based on my investing principles. the scans should be tools to find opportunities through various means."

PR #53 made NOVA_SCORE_WEIGHTS the single canonical weight scheme. Every blender call — runScan, explainSymbol, AI Picks, chat — reads the same weights. Presets become discovery tools rather than re-weightings: they express identity via filters.layerFloors (per-layer score floors), strategy hooks, and universeSpec (their natural universe). ScreenerPreset.weights is deprecated; smart-money lens presets force direction = "long" matching the Lookup behavior.

The same PR added the new flagship preset, Nova Conviction: no lens filter, ranks the sp500∪sp400 union by universal Nova Score. The cleanest expression of "show me my best candidates by my investing principles." It also added Lookup deep-links from every Scanner and AI Picks result — clickable symbol headers and explicit "Open in Lookup" buttons — mirroring what Smart Money already does.

And then we spot-checked again. MSFT scored 79 on Scanner. 81 on Lookup. Same architecture. Same universal weights. Same data sources. Different number.

The chain

What followed was the kind of debug arc you remember. Eleven commits to chase down why two paths reading the same weights against the same data still produced different numbers.

The first few were structural rather than buggy — the kind of thing that's been fine because the answer was always "tech-driven enrichSet works." Under the universal-scoring world, it stopped working.

  • Nova Conviction's minConviction: 70 floor returned zero ideas. Universal scoring with a -0.25 contrarian tech weight rarely lets routine large-caps blend to 70+. Floor dropped, pure rank top-N.
  • ENRICH_TOP_N bumped 60 to 250 as a band-aid. The tech-only enrichSet was systematically selecting against the names most likely to top a smart-money blend — MSFT at long-bias tech ~67 missed the top 60 in sp500+sp400, so its enriched layers never even fired in Scanner.
  • Real fix: a smart-money-aware enrichSet pre-filter. New smart-money-prefilter.ts builds a per-symbol proxy from already-cached data — no new network calls. 13F holder count, Form 4 cache freshness, congress trade count. Blend 0.4 × tech + 0.6 × smart-money proxy, pick top-N. ENRICH_TOP_N back to 100. The enriched set is now a real candidate set, not a tech-momentum proxy.
  • warmAllFunds was only triggered by visiting Lookup or Smart Money. Scanner-first sessions found a cold cache. Now it auto-warms at app startup, fire-and-forget.
  • Per-fund timeout in warmAllFunds bumped 12s to 30s. The previous timeout was firing on stuck OpenFIGI calls and silently dropping funds. The "5 of 26 still hydrating" status had been stuck for hours.
  • SEC rate-limit handling. Rapid Electron restarts during debugging tripped SEC's 10/sec cap, which produced a 10-minute IP block returning the string "Request Rate Threshold Exceeded" as HTML. We were caching that as the response. Now: 8/sec global throttle and an explicit detector for the throttle HTML so we never poison cache with empty results during a block.
  • Broken-fund skip-list. Three consecutive failures puts a fund on a one-week skip. Manual reset via brokenFundsClear().

The 13F XML parser bug

This is the load-bearing one for the "five funds stuck" mystery.

Five major funds — Bridgewater, Baupost, Third Point, Oaktree, Altimeter — all file their 13F-HR XMLs with namespace-prefixed tags. Where the others use <infoTable>, those five use <ns1:infoTable>. Our regex parser was matching the bare tag only. It silently returned zero matches. getHydratedFiling returned null. No error thrown.

The funds appeared to "fail." They weren't failing. The parser was blind to their tag style.

The fix is one regex change: match optional xx: prefix on every tag. After the fix, those five funds returned full holdings. Five funds × every ticker they collectively held had been silently under-scoring on the 13F layer for weeks. That's not a small data issue — it's most of the smart-money universe missing one-fifth of its named-fund coverage.

The Finnhub 429-poisons-cache bug

Diagnostic logs — the right answer for divergence bugs — showed MSFT in runScan with fundamental = 50 (abs) and valuation = 50 (abs). Both abstaining. Fundamental and valuation are the two heaviest weights in the universal scheme (1.0 and 0.9). Half the universe was missing both layers and we hadn't noticed because the score still came out plausible.

The cause: Finnhub's free tier caps at 60 req/min. runScan was firing about 100 symbol fetches at concurrency 4 — bursting over the cap immediately. 429 responses poisoned the in-memory cache with empty snapshots for 30 minutes (the negative-cache TTL). Fundamental and valuation signals abstained on every poisoned ticker for the next half hour, which on a typical scan means "for the rest of the session."

This is the bug behind both "MSFT scoring 79 vs 81 on Lookup" and "Nova Conviction tops at 66 while AI Picks shows 70+." Half the universe was scoring its two heaviest layers as abstain because of a 60/min rate limit we'd been quietly stomping every scan.

The fix: a 1.1-second minimum interval between Finnhub calls (~55/min, comfortably under the cap), and 429 responses no longer write to the cache at all. They retry after the minimum interval.

One more: the parallel rebuild explosion

Even with Finnhub throttled, runScan reported "13F holders = 9 of 22" while explainSymbol reported "13 of 26" for the same ticker. runScan's 100 parallel getSymbolFundOwnership calls × 26 cached funds = 2,600 concurrent fund-detail rebuilds. Under load some of those hit the 12-second timeout and dropped out. Different scan run, different dropouts, different reported holder count.

Fix: a module-level memo with 1-hour TTL plus in-flight promise dedupe. When 100 callers ask for the same fund detail, they all wait on the same in-flight build instead of triggering 100 of them. The 2,600-rebuild fan-out collapses to about 26 builds.

End-to-end parity

After all of it: runScan and explainSymbol both produce MSFT score 80.6. Identical layer breakdown except catalyst (50 abs in scan, 64 in Lookup — cosmetic only, since the universal weight on catalyst is 0). The score is finally truly universal across every surface.

The pattern that came out of this, banked as a memory: "silent data degradation" audit. When scoring looks off, audit every data source for four failure modes: (a) parser bugs that produce zero-results silently, (b) rate limits that poison caches with empty data, (c) parallel-call patterns that exceed implicit limits, (d) per-call rebuilds that should be memoized. Today's chain hit all four.

The other thing worth keeping: diagnostic logging is the right answer for divergence bugs. Side-by-side logging of MSFT's per-layer reads from both runScan and explainSymbol pinpointed the Finnhub bug in seconds after weeks of speculation about what was wrong. When two paths produce different numbers, log both, then remove the logs once parity is confirmed.

Where this leaves Nova

The score is universal. Same number on every surface. Twelve layers, one canonical weight scheme, abstain semantics throughout, smart-money-aware candidate selection, every fund actually parsed, no rate-limit cache poisoning, no parallel-rebuild fan-out.

Two things shipped on top while we were here, which will get their own posts soon: a relative scoring lens for "hidden gems" mode (universal score stays the anchor; a separate per-peer-group percentile chip surfaces small/mid-caps that score lower under universal but rank highly within their slice), and the Phase 10 Macro tab (Economic Dashboard via FRED, Volatility Dashboard via Yahoo VIX/SKEW/term-structure, Polymarket via the Gamma API with a macro-relevance filter).

Phase 11 is next: sector rotation, market heatmap, themes library. Mostly UI on data Nova already has.

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