*Last updated: 2026-08-24 (the dividend yield reaches the option math — it was parsed and then
dropped by a hand-typed field list, so q was 0 on every payer since 2026-08-10 and this
document blamed the provider for it; the merge now copies every field the provider returns and
§7 carries the correction. Previously: the trust surface tuned against its first live day: the macro
prose check is clause-aware so a direction word after "while/as/but" is not charged to the
index, and the writers get 4 searches with 6-row slates accepted so a capped research pass
always has a legal JSON output instead of starving into narration. Previously: the briefing
survives its own generator: a live-authored result inside its TTL is never regenerated, a failed generation keeps the last-good cards instead of clobbering them, the Hype validation lookup reads the path the screener actually populates and is a tested export, and model output is extracted by a balanced scanner so narration around the JSON cannot blank a section. Previously: the seven-item 2026-08-12 review — one time basis for "today", Hype numeric validation, N(d2) horizon phrasing everywhere with a repo-wide copy guard, the signal vocabulary translated on every surface, letters banded on the displayed composite, per-contract derived moneyness, Factor Lab interpreting the result it displays, and the text/host/indexing hygiene batch. Previously: the briefings state their provenance instead of citing sources — AI-written, not individually sourced, with the measured data beside them named as such, see §7g-c; an expanded analysis is addressable as ?symbol=TICKER and the backtest's default parameters are warmed at boot; the thesis builder replaces its own introduction. Previously: a featured LEAP contract must pay off at its own scenario price, and the four valuation references — consensus target, haircut target, model fair value, scenario price — are named distinctly, see §4; ETF and fund holdings are classified by mandate into the sector they are exposure to, see §7g-b; the editorial layer dates itself in ET like the rest of the app, and a macro release is never priced as a security. Previously: the option math is ready to net out dividend yield — believed at the time to be unsupplied by the provider, corrected 2026-08-24, see §7 — and an assumed implied vol is labelled rather than scored as measured. Coverage is measured against ACHIEVABLE factor weight; the reverse DCF is sector-aware and respects the Financials veto; the warm layer carries per-FIELD freshness — see §1, §2 and §8. The risk-free rate reads live from the Treasury curve — §2; universe 1,000; honest caveats on the two factors that behave
differently on the live keyless path — see §3). The scoring engine lives in lib/scoring.js;
its core math is pinned by known-answer tests in tests/scoring-unit.mjs.*
Every number the app shows is computed deterministically from data — no hidden judgment.
This document states the exact weights, formulas, and thresholds behind each score, cross-referenced
to the functions in lib/scoring.js and server.js. Change a number in the code and the output changes; that is the point.
Guiding principle: our own calculations lead; analyst consensus is a supplement, never the driver.
Analyst price targets are consensus, so leaning on them can't surface mispricings — they only confirm.
scoreTickerA name's grade is a weighted blend of five component scores, each 0–100. Missing inputs are dropped,
the remaining weights renormalized, and the result is then **shrunk toward neutral 50 in proportion to the
missing weight** (50 + (raw−50) × (0.5 + 0.5×coverage), 2026-07-15) — so a name graded off two components
can no longer outrank one graded off five. The coverage field (formerly mislabeled "confidence") is the
% of input weight present: data coverage, not conviction.
Coverage is measured against what is ACHIEVABLE, not against the published vector (2026-08-08).
A factor that is null for every name in a crawl is not obtainable on that data path, so it leaves
the denominator entirely — otherwise it acts as a constant that carries no information. Before this,
estMomentum (0.08, see §3) was null universe-wide on the keyless crawl, so every composite in the
1,000-name universe was compressed 4% toward 50 by that constant and the displayed coverage figure was
structurally incapable of reading above 92% — a reader could not distinguish "this name is missing
data" from "nobody can have this factor". A factor missing for only some names still penalises those
names, which is what the shrink is for. Derived per crawl, not hardcoded.
| Component | Function | How it's scored |
|---|---|---|
| Value | valueScore | Blend 0.6 × our DCF upside + 0.4 × analyst-target upside when both exist (the monitor computes the same WACC-discounted computeDCF as the screener, 2026-07-15). Analyst-only haircut: when the target is the sole input its upside is cut 15% (TARGET_HAIRCUT) — sell-side targets run systematically optimistic. Mapped 35 + blend×160, clamped 0–100 (−20%→0, 0%→35, +25%→75, +50%+→100). Fallback if neither exists: 60% of the gap to the 52-week high, score capped at 60 — the falling-knife guard: a big gap-to-high must never outrank a target/DCF-sourced value case. |
| Trend | trendScore | Above 200-DMA → base 70, else 25. Golden cross (50>200) +15 (else −10). Above 50-DMA +10 (else −5). −4 if only a 50-DMA proxy is available. |
| Entry/timing | entryScore | Base 50. RSI ≤35 +25 / ≤45 +12 / ≥60 −10 / ≥70 −25. At support (≤2.5%) +18, ≤6% +8, below support −20, >20% above support −8. Extended above 50-DMA: ≥8% −7, ≥15% −15. |
| Rating | ratingScore | Analyst consensus skew net = (buy−sell)/total; base 50 + net×45. Small nudge for target upside, capped at [−15, +20]. |
| Quality | qualityScore | Weighted: ROIC−WACC value-creation spread 40% (2026-07-17: ROIC [ROE proxy] minus the name's own Blume-CAPM cost of capital — the same rate the DCF discounts at; spread 0→50, +10pp→75, −10pp→25. Previously an absolute ROIC curve, 20%→85 / 10%→50, which gave every name the same hurdle), net debt/EBITDA 35% (net cash→100, banded down to 10 at ≥5×), FCF yield 25% (5%→80). |
Per-list weights & curve (LIST_PROFILE) — each list grades on its own curve on purpose:
| List | value | trend | entry | rating | quality | curve |
|---|---|---|---|---|---|---|
| Own | 0.25 | 0.22 | 0.18 | 0.18 | 0.17 | 0 |
| Quality Value ('deep') | 0.40 | 0.18 | 0.12 | 0.15 | 0.15 | +3 |
| LEAPS | 0.28 | 0.28 | 0.22 | 0.12 | 0.10 | 0 |
| Parabolic | 0.38 | 0.18 | 0.18 | 0.16 | 0.10 | +8 |
Composite → letter (letter, applied to round(composite) + curve):
A ≥86 · A− ≥80 · B+ ≥74 · B ≥66 · B− ≥60 · C+ ≥54 · C ≥46 · else C−.
The letter is a function of the displayed (rounded) composite — banding the raw float let
79.5 ship as "80 · B+" while 80.4 shipped as "80 · A−", the same visible score wearing two
grades (2026-08-12). Curves are whole points, so nothing else moves.
Signal (signal) — the rule the backtest said mattered: never buy support in a downtrend.
The user-facing vocabulary is “Entry met” / “Wait” / “Trend broken” / “Data too thin”
(web/src/signal.js); the stored codes in parentheses persist unchanged in the ledger,
snapshots and /api/* payloads — renaming the data would reinterpret every recorded row.
REVIEW) — thesis needs re-confirmation; wait for reclaim. Below support → same.BUY).WAIT).VERIFY).)Sizing (sizing): Parabolic → Starter/Lottery. Below 200-DMA → Starter. Large-cap (≥$200B) & composite ≥78 → Core. Mid (≥$20B) & ≥66 → Standard. Else Starter.
Reward:risk (rewardRisk): value-blend upside ÷ risk, where risk = max(distance-to-support, 8%) in a clean uptrend (RISK_FLOOR, 2026-07-15 — support breaks, so sitting 1.5% above a floor is not 1.5% of risk and can no longer print 20:1 ratios), else a 12% (uptrend) / 20% (downtrend) band.
SECTOR_PROFILES, resolveSectorProfile (2026-07-23)The same five assumptions don't fit a bank, a miner, and a SaaS company — so every name is scored
through a sector profile that adjusts three things, each with a stated rationale shown on the
card ("How this grade works"):
1. DCF assumptions — the growth cap (a trough-year memory rebound or peak-cycle oil FCF must not
extrapolate for 5 years), the terminal growth rate, an optional discount floor, or a full veto
(dcf: false) where an FCF DCF is not meaningful.
2. Monitor component-weight tilts — multipliers (0.8–1.25 by design) composed onto the list
weights (LIST_PROFILE), then renormalized to sum 1. The sector shades the lens; it never
replaces the engine.
3. Quality lens shape — the ROIC/debt/FCF part weights, plus debtShift, which moves the
net-debt/EBITDA thresholds right where structural leverage is normal.
4. Screener factor tilts — same multiplier treatment on SCREEN_W, renormalized.
| Profile | DCF growth cap / terminal | Key tilts | Quality lens |
|---|---|---|---|
| Semiconductors | 20% / 2.5% | trend ×1.15, entry ×1.10 | standard |
| Software | 30% / 3.0% | quality ×1.15 | ROIC-tilted (.45/.25/.30) |
| Internet platforms | 30% / 3.0% | quality ×1.15, rating ×1.05 | ROIC-tilted |
| AI infrastructure | 30% / 2.5%, r ≥ 9% | trend ×1.20, quality ×0.85 | standard |
| Payments | 25% / 3.0% | quality ×1.15 | ROIC-tilted |
| Healthcare | 25% / 2.5% | rating ×1.20 | standard |
| Industrials | 15% / 2.5% | quality ×1.10, value ×1.05 | standard |
| Consumer | 15% / 2.5% | entry ×1.10 | standard |
| Energy | 8% / 2.0% | quality ×1.20, trend ×1.15, value ×0.80 | debt-tilted (.35/.40/.25) |
| Materials | 10% / 2.0% | quality ×1.15, trend ×1.10 | debt-tilted |
| Financials | DCF vetoed | rating ×1.15, value ×0.85 | ROE vs cost of equity only (debt/FCF parts dropped — meaningless for balance-sheet businesses) |
| REITs | 6% / 2.0% | entry ×1.05 | debtShift +3 (5× EBITDA is normal), FCF-tilted |
| Telecom & media | 8% / 2.0% | value ×1.05 | debtShift +1.5, FCF-tilted (.30/.30/.40) |
| Autos | 10% / 2.0% | trend ×1.10, quality ×1.10 | debt-tilted |
| Utilities | 6% / 2.0% | quality ×1.10, entry ×1.05 | debtShift +2 |
| Generalist (default) | 30% / 2.5% | none | standard (.40/.35/.25) |
Resolution order: curated SECTOR_BY_TICKER (the hand-assigned SCREEN_LIST sector) → row's own
sector → Yahoo assetProfile sector folded through YAHOO_SECTOR_MAP → default (neutral,
identical to pre-profile behavior). When the Financials veto suppresses the DCF, the value component
falls back to the analyst target with the standard 15% haircut, and the card says so explicitly
(detail.sectorProfile.dcfNote). The effective post-tilt weights are published per card in
detail.sectorProfile.weights — the numbers on screen are the numbers used.
computeDCFOur own 5-year discounted-cash-flow model — this, not FMP, is what the app's "DCF" means.
min(cap, forward-or-trailing revenue growth) down to a terminal rate over5 years — the cap and terminal rate come from the sector profile (§1b); the generalist default is 30% → 2.5%.
capmDiscount): r = clamp(8%…12%, rf + β* × 4.7%) where rf is the live 2-year Treasury CMT (FRED H.15, refreshed at least daily; /api/health → riskFree reports the exact rate and its as-of date, and the engine falls back to 4.3% only if it has never had a good curve read) where β* = 0.33 + 0.67β (Blume shrinkage — raw historical
betas overshoot). Names with no beta use the old flat 10%. Why: the 2026-07-10 parallel
analysis showed flat-10% treats a β0.25 utility and a β2.0 speculative name as equally risky,
while raw (un-shrunk) CAPM over-tilted rankings toward low-beta defensives (+42pp median upside);
the Blume + 8–12% clamp variant kept rank correlation ≈0.975 with sensible risk adjustment.
local-currency financials against a USD market cap: TSM/PDD/JD…; float-inflated insurer FCF:
ALL/TRV/AIG), not an opportunity. dcfFactor then falls back to the analyst target.
FCF₅(1+g) / (r−g), discounted back.The full year-by-year waterfall (incl. the discount source) is shown in the Screener card so it can be checked by hand.
Reverse DCF (impliedGrowth, 2026-07-15; made genuinely sector-aware 2026-08-08): the same machinery run backwards — it now reads the sector profile's terminal rate and discount floor, and honours the Financials FCF-DCF veto. Until then it hardcoded a 2.5% terminal and no veto, so it disagreed with the forward DCF for 9 of the 16 profiles, and produced an implied-growth figure for 37 of 80 banks from the exact machinery the profile had just suppressed (which was also recorded into every ledger snapshot as ig). Bisection solves for
the year-1 FCF growth rate the current market price implies (searched over −60%…+150%; null when the
price sits outside that band). A stock isn't cheap because a DCF says so; it's cheap when the market's
implied growth is mathematically below what the company delivers. Shown on screener rows and monitor
tear sheets next to the forward-growth estimate, so the disagreement — the edge, or the error — is explicit.
The two lenses run on different growth premises, and the card now says so (2026-07-30). The forward
DCF credits at most the sector growth cap (20% for Semiconductors, 30% default); the reverse DCF is
uncapped across its −60%…+150% search band. On a name growing far above its cap both figures are
correct and they read as a contradiction: NVDA showed "DCF fair value $32 (−83% vs price)" directly
beside "the market is pricing less growth than the numbers show." The first credits 20% growth, the
second credits 214%. Where the cap binds, the fair-value sentence now names it — *"deliberately
conservative: the model credits at most 20% year-1 growth (the Semiconductors cap), not the >+200% the
data shows, so read it as a floor rather than a target."* No score changed; the sector A/B contract is
byte-identical. This is a disclosure fix, and it is pinned in tests/scoring-unit.mjs in both
directions — capped names must carry the note, uncapped names must not.
Value-creation spread (2026-07-17): every card surfaces ROIC vs WACC — return on capital
(Yahoo ROE, an admitted proxy) against the same CAPM discount rate the DCF uses — as a signed
spread in points. Positive spread + growth compounds value; negative spread means growth destroys
it. Shown in the tear-sheet valuation summary, the screener Value line, the financials blurb, and
Decision Queue evidence; recorded in every ledger snapshot. The monitor tear sheet's Fundamentals
tab now renders the full DCF waterfall (same DcfBlock glass box as the screener — assumptions,
per-year projection, terminal value, EV→equity→per-share, input provenance).
Context comparators (2026-07-15): sector medians of P/E and RSI (robust median over the
screener's sector distributions) accompany the raw values on cards — a multiple without its sector curve
is noise, and an oversold RSI in a firm sector is a stronger signal than one falling with its group.
FCF yield is shown against the live risk-free rate (the spread is the equity risk premium actually
on offer). The Black–Scholes blurb now surfaces the tension when a target implies ≥25% upside but
the name's own volatility prices finishing there in a year at under 25% odds.
scoreScreen, weights SCREEN_W| Factor | Weight | Source | |
|---|---|---|---|
| DCF upside | 0.18 | computeDCF (ours, CAPM-discounted §2). When neither DCF path resolves (no positive FCF, the Financials veto, or the >5x artifact guard) dcfFactor falls back to the analyst target with the standard 15% haircut, and detail.dcfSrc records which fired. Measured on the live universe 2026-08-06: 697 of 1,000 rows are a real DCF model, 303 are the haircut-target fallback. | |
| Technicals | 0.11 | trend/RSI/support | |
| Revenue growth | 0.12 | Yahoo — 60% absolute + 40% sector-relative | |
| Market/forward growth | 0.09 | Yahoo — 60% absolute + 40% sector-relative. ⚠ Honest caveat (2026-07-27): on the keyless Yahoo-only crawl (the live default), the "forward" input is Yahoo's earningsGrowth, a trailing figure used as a proxy — so this factor is correlated with Revenue growth and Growth quality's EPS component rather than independently forward-looking. True forward estimates require the FMP path. | |
| Balance sheet | 0.12 | FCF yield 0.35 · leverage 0.30 · EBITDA margin 0.20 (60/40 blend) · gross margin 0.15 (sector-relative) | |
| Growth quality | 0.12 | ⚠ margin trend (0.20 of this factor) is structurally null in live production — it needs opIncomeGrowth, which only the FMP branch sets and the keyless screener crawl skips; it is also absent from the warm-start fields, so nothing backfills it. The remaining three parts renormalize. | EPS growth 0.35 · margin trend 0.20 · ROIC−WACC spread 0.25 (2026-07-17: vs each name's own Blume-CAPM cost of capital; was a flat 9% hurdle) · Rule of 40 0.20 (sector-relative, clamped inputs) |
| Est. momentum | 0.08 | analyst revisions. ⚠ Honest caveat (2026-07-27): structurally null in live production — the input comes only from the Finnhub branch, which the Yahoo-only screener crawl skips, so this 8% is renormalized across the other factors for every screener name. Since 2026-08-08 it also leaves the coverage denominator (§1) — previously it was renormalized and charged as a 4% coverage penalty to every name, which is why screener coverage topped out at 92%. The weight is published because the factor fires when keys/paths allow. | |
| Valuation | 0.09 | PEG first → sector-relative EV/EBITDA (no-PEG fallback) → P/E band | |
| Price momentum (12-1) | 0.09 | momentumFactor — trailing-year return excl. the last month, ranked cross-sectionally (robust z vs the whole universe). Adopted 2026-07-14 from the Factor Lab's walk-forward evidence (§7d: 6-month IC 0.043, t = 1.98, 42 rebalances); weight trimmed pro-rata from the other factors (prior weights: .20/.12/.13/.10/.13/.13/.09/.10). |
Analyst price targets are not an independent factor in this composite — there is no analyst-target line item, and the Rating factor scores consensus skew, not the target level. They do enter indirectly, for one row in three, as dcfFactor's haircut fallback above; detail.dcfSrc says so per name. (Until 2026-08-06 this sentence read "not in this composite" flatly, which the live data contradicted for 30% of the universe.)
Sector-relative scoring (adopted 2026-07-11, → buildSectorStats/sectorZ/blendZ): sector-structural
metrics are judged against the name's own sector — 30% revenue growth is mediocre for software but elite
for industrials; 40× EV/EBITDA is normal for semis, absurd for telecom. Mechanics: robust z-score within
sector (median + MAD, so one outlier can't warp the curve), mapped to 0–100 via 50 + z×20; sectors with
fewer than 8 usable values fall back to the whole-universe distribution. Growth/margin factors **blend 60%
absolute + 40% relative** so a shrinking sector can't mint winners purely by curve ("best house in a bad
neighborhood" guard); Rule of 40 (inputs clamped: growth ±150pp, margin ±100pp), EV/EBITDA and gross margin
enter purely sector-relative. Each card's "Model context" line shows the metric next to its sector median.
scoreLeap, weights LEAP_WHunts the whole universe for a strong, favorably-priced, paying-off option. Scored on the balanced (~ATM) contract. Two structural rules (2026-07-12): contracts whose ATM implied vol is < 12% are rejected as stale quotes on illiquid chains (not bargains), and the top 15 carries max 3 names per sector — the upside components saturate above ~+78%, so a single hot theme (e.g. peak-margin commodity DCFs) would otherwise sweep every slot:
| Component | Weight | Meaning |
|---|---|---|
| Conviction | 0.18 | the stock's screener composite |
| Upside | 0.15 | stock's distance to our DCF (analyst target only as fallback) |
Candidate selection — a measured inconsistency, disclosed rather than quietly changed (2026-08-06). Only 50 of the universe get an option chain fetched, and that gate pre-ranks on the raw analyst upside while the score above prefers the 15%-haircut figure — so the haircut is applied to the ranking and bypassed by the gate feeding it. Measured on the live universe: 31% of all names have a haircut-target-sourced dcfSrc, but 66% of the current candidate pool does (a dcfUpside-first gate would be 10%, with a median composite of 73 vs 70). The two pools overlap only 12 of 50, so switching would replace most of the LEAPS list. It is left as-is until the scorecard has forward returns to choose on (fwd21 n=0 today; first verdicts ~mid-Aug 2026); if it changes, the change gets its own regime split so the before/after is graded separately.
| Value edge | 0.24 | implied vol vs the stock's realized vol — implied below realized = a cheap option (mispricing) |
| Option return | 0.23 | the contract's % return if the stock finishes at fair value by expiry |
| Odds of profit | 0.12 | model-implied chance of finishing above break-even |
| Liquidity | 0.08 | open interest |
Contracts, greeks, and probabilities come from live CBOE data + Black-Scholes (below). Strikes
are chosen by delta (~0.75 conservative / ~0.55 balanced / ~0.35 aggressive); each
contract's actual moneyness (ITM/ATM/OTM, ±2% of spot = ATM) is derived from strike vs spot
and shown on its tab — the tier names how the contract was picked, never what it is, because
on a high-IV LEAP the Δ0.75 pick can sit above spot (2026-08-12).
Viability gate on the featured contract (2026-08-11). The three tiers are chosen by delta
alone, and nothing compared the strike to the scenario anchor — so whenever our own fair value
sat at or below the ~ATM strike, that contract's scenario return came out negative and it was
still the one featured on a card headed "Best LEAP opportunities". Measured on the live list the
day this was found: 4 of 15 rows featured a losing contract — POET at −100%, IREN −95%,
TSM −50%, STNE 0% — each meaning "if the stock finishes at the price we ourselves think it is worth,
this trade still loses money".
otherwise the best tier that does. featuredTier travels in the payload, and the card opens,
quotes and sorts on that contract — the score and the headline describe the same trade.
list. It is not an opportunity.
scenReturnPct === null (no DCF and no analyst target, so nothing to judge against) isunjudgeable, not failing — those pass through as before rather than being silently culled
for missing data.
more. Only the featured one is gated.
The score is computed on the featured contract, so this also makes the ranking coherent — a name
whose best viable strike barely pays off now scores like it.
Naming (same date). One card called two different numbers "target": the analyst consensus in
the header and the DCF fair value the scenario was actually run against — "consensus target $28"
could sit beside "+164% if target hit" computed from $73. The four references are now distinct
throughout: consensus target, haircut consensus target, model fair value (DCF), and
the scenario price the contract's return is computed at (each card names which anchor it used).
scoreUpsideNameModel-first ceiling: max(DCF upside, revenue-growth proxy, distance-to-52-wk-high) + volatility×0.5,
then blended 70% our model + 30% analyst target (analyst is a supplement, never the driver). Capped at +500%.
Risk = max(annualized volatility, max-drawdown×0.8), ×1.2 when below the 200-DMA. Reward:risk = ceiling ÷ risk.
scoreETFRowWeights: Growth 0.28 · Value 0.27 · Technicals 0.25 · Fund financials 0.20.
Growth/value come from the holdings' aggregate fundamentals (Yahoo topHoldings); financials = the fund's P/CF, expense ratio, yield. Only equity-holding ETFs (bond/physical-commodity funds are excluded — no holdings fundamentals to score). Each row also states what the fund is exposure to, from the same mandate table the portfolio classifies with (§7g-b).
blackScholesBlurb, bsCall, _normCdfFrom each name's own annualized volatility (σ, from price history) over a 1-year horizon, risk-free rate RF_RATE (the live 2-year CMT, §2):
price × e^(drift ± σ), where drift = r − σ²/2.N(d2) (standard normal viaAbramowitz–Stegun). A horizon probability, not a touch probability — a path that tags the
target mid-year and falls back counts as a miss. Every surface words it accordingly (2026-08-12).
bsCall takes a continuous yield q and prices S·e^(−qT)·N(d₁) − K·e^(−rT)·N(d₂), with delta e^(−qT)·N(d₁). A call holder receives no
dividends, so omitting it overstated the forward on every payer — on an 18-month LEAP over a
3–4% yielder that is ~5–6%, inflating the premium, the delta used to PICK the strike, and both
probability figures alike. The yield comes from Yahoo summaryDetail, which the fundamentals
crawl already fetches, so it costs no extra request. Non-payers are unaffected: q = 0
reproduces the previous formula exactly.
**Correction (2026-08-24): this section previously said the provider was not supplying the
yield. That was wrong, and the error was ours.** From 2026-08-10 to 2026-08-24 the term was
inert — q = 0 for every name — but not for the reason stated here. Yahoo answers
summaryDetail.dividendYield on request (KO 2.33%, XOM 2.50%, measured 2026-08-24) and
fetchYahooFundamentals parsed it correctly the whole time; fetchTicker then merged the
result through a hand-typed list of field names that omitted divYield, so the value was
read and discarded one line later. /api/health.inputs.divYieldPctOfUniverse correctly
reported 0 the entire time — the instrument worked, the diagnosis did not.
The merge now copies every field the provider returns (mergeYahooFundamentals), so the
class cannot recur, and tests/provider-merge.mjs fails on the old code. **Option figures
on dividend payers dated before 2026-08-24 are slightly optimistic** — premium, the delta
used to select the strike, probITM and probProfit were all computed without the
dividend discount.
omits impliedVolatility the ladder still needs a number to select strikes by delta, and it
used 40%. That invented figure was then displayed as "Impl. vol 40%" and fed the vol-edge term
that is 24% of the LEAP score. Contracts now carry ivAssumed, the card says "(assumed — the
feed gave none)", and one-sided quotes are flagged quoteStale (the ladder prices off the MID;
a lone stale lastPrice is not a price). The bid/ask spread is now displayed and flagged
above 25% of mid — it was computed server-side and shown nowhere, so an untradeable 0.40 × 1.60
quote read exactly like a tight one.
Labeled a model estimate, not a forecast — risk-neutral, so it's the honest/conservative reading.
buildRadar, weights RADAR_WReuses the screener's already-scored rows (zero extra fetching). Eligibility gates: screener composite ≥ 55 (quality first — our calculated factors only), market cap ≤ $50B, ≤ 8 covering analysts (numberOfAnalystOpinions), and not currently trending on StockTwits.
Radar score = 0.55 × composite + 0.25 × coverage gap + 0.20 × ownership gap, where
coverage gap = clamp(100 − analysts × 11) and ownership gap = clamp(110 − instOwn% × 1.4) (unknown ownership scores a neutral 50). Analyst targets play no part — coverage count is used only as a neglect signal (fewer = more overlooked). Top 15 shown.
Why institutional ownership is a score input but NOT a gate (revised 2026-07-14): live data showed
heldPercentInstitutions is unreliable at the small end — 164 of 600 names reported >100% (float vs
shares-outstanding and double-counted 13F artifacts) — and in the passive-index era even $2B companies are
~90% "institution-owned" mechanically, so a ≤70% gate matched almost nothing (4 names in a 651-name
universe). Sell-side analyst coverage is the honest neglect signal. The bounded ownership component
is artifact-safe: anything ≥79% already scores zero on that leg.
precisionAnalysisBorn 2026-07-10 as the read-only "parallel analysis" used to trial sharper methodology before adoption. Everything it trialed has since graduated into the scored model: the WACC-DCF on 2026-07-11 morning (§2), and Rule of 40 / EV-EBITDA / gross margin later that day as sector-relative inputs (§3). The line now provides transparency instead: each adopted metric shown next to its sector median (the yardstick it's scored against), plus two references that remain informational only — the flat-10% DCF (pre-adoption model, for continuity) and EV/Sales.
runFactorLabThe evidence layer the composite weights answer to. Monthly walk-forward across the whole screening
universe (live: 42 rebalances over ~3.5 years, 545 names): at each date, price-derived factors are
computed only from data available then (12-1 momentum, trend vs 200-DMA, RSI-14, 63-day volatility,
52-week-high proximity, and a blended price composite), then forward 1/3/6-month returns are measured
across the cross-section. Reported per factor × horizon: mean Spearman IC, a t-statistic
(mean/std × √n), % of dates positive, and the average top-minus-bottom quintile spread.
Honesty constraints (also shown in the UI): fundamental factors (DCF, growth, margins, valuation)
are not tested — only today's fundamentals exist, and applying them to past prices is look-ahead
bias; the universe is today's list (survivorship bias); results are gross of costs. First live run
(2026-07-14): trend vs 200-DMA was the strongest validated factor (t = 3.64, 70% of months positive) —
supporting the composite's Technicals weight and the trend-break (“Trend broken”) rule — and 12-1 momentum cleared
significance (t = 1.98), leading to its adoption at 9% (§3). The high-volatility "signal" (t = 2.98)
was deliberately not adopted: a bull-regime + survivorship artifact.
computePulse, endpoint /api/pulseThe macro monitor that fronts the Home view (2026-07-15): sector breadth heat (per sector, % of
screened names above their 200-day average + median composite + median 12-1 momentum — derived from the
screener's already-scored rows, zero extra fetching), overall breadth (% of the whole screened
universe above the 200-day), and the Treasury yield curve — official **FRED H.15 constant-maturity
yields** (keyless CSV, series DGS3MO/DGS2/DGS5/DGS10/DGS30; ~1-business-day lag, as-of date shown on
screen) with the 10y−3m spread and an inversion flag. Yahoo index tickers (^IRX/^FVX/^TNX/^TYX)
remain only as a labeled fallback — ^IRX is a 13-week discount rate, ~10–15bp under the published 3M
CMT, which is why the Yahoo-sourced curve read slightly off. Cached 30 min; an empty-breadth pulse
(screener still crawling) expires in ~2 min instead.
The screener composite carries the same coverage shrink as the monitor (missing factor weight pulls
the composite toward 50), and dcfFactor's analyst-target fallback gets the same 15% haircut as
valueScore — the sell-side number is never taken at face value when it's the sole input.
buildSnapshot / computeLedger, endpoints /api/snapshot /api/ledgerThe accountability layer (2026-07-16): the engine's opinions are recorded, then graded.
/api/snapshot (per name: price, grade, composite, signal, sizing, and the fundamentals — FCF yield fy, ROIC ro,
WACC wa, ROIC−WACC value spread vs, reverse-DCF implied growth ig, modeled growth mg,
P/E pe, revenue growth rg (added 2026-07-17); plus an SPY benchmark quote and the average
data coverage) and commits it to the repo's ledger branch. The fundamentals make each
snapshot a genuine point-in-time record, which is what will let the Factor Lab
walk-forward test fundamental factors without look-ahead bias once history accrues (snapshots/YYYY-MM-DD.json +
index.json). The branch is append-only in practice and public — a track record nobody can
quietly rewrite. Render's free disk is ephemeral; the repo is the durable store. Validation:
never commits mock data, near-empty reads, or (after 3 retries) low-coverage reads.
computeLedger, cached 6h): reads the snapshots back and computes signal flips(with 5- and 21-snapshot forward returns, absolute and minus SPY), the **“Entry met”-flip hit
rate (flips into BUY), and hit rate by grade bucket** (every daily observation's
~1-month forward return, pooled by A/B/C — the test of whether grades mean anything).
give a rolling hit rate; below 45% the app says, on screen, *"the strategy is out of regime —
trust the signals less."* Needs ≥8 measured flips before it speaks.
returns; grade buckets overweight names that stay in the universe.
The calibration scorecard (2026-07-23, → lib/scorecard.js, rendered in the Signal Ledger):
three questions the ledger now answers rigorously, each activating only when the sample supports it:
1. Do the signals differ the way they claim to? Per-signal (BUY/WAIT/REVIEW/VERIFY)
pooled ~1-month forward stats — “Entry met” must out-return “Wait” must out-return
“Trend broken”, or the rule is noise.
2. Does a higher composite predict a higher return? Per-snapshot-date Spearman rank IC
between composite and measured 21-session forward return (cohorts need ≥8 scored names);
reported as mean IC, t-statistic, and % of dates positive — pooled and split at 2026-07-23,
the sector-profile regime change, so the post-split series is the live verdict on sector-aware
scoring. ~0.05 mean IC is respectable for a live monthly-horizon signal.
3. When the model states odds, do they happen at that rate? Every snapshot records the
volatility model's own probabilities at observation time — p21 = P(higher in 21 sessions)
and pt = the card's published "chance of finishing at or above the analyst target one year from now" (same
Black–Scholes machinery, same numbers the user sees). Realized outcomes are binned against
predicted odds (40–45 / 45–50 / 50–55 / 55–60%): the predicted and realized columns should
match, bin by bin. The 21-session panel forms at 40 scored pairs; the 1-year target-odds
panel matures on a 1-year clock by construction and reports its accrual until then. Odds are
recorded at snapshot time, never backfilled — pre-2026-07-23 snapshots simply lack them.
Since the repo went private (2026-07-23) the ledger/warm-start reads authenticate with a
GITHUB_TOKEN (fine-grained PAT, Contents: read) in the server environment; the daily snapshot
Action needs no change (it writes with the workflow's own token).
concentration, regression beta vs SPY (1y daily, current weights held constant — labeled an
approximation), annualized σ, 1-y max drawdown, and the correlated-cluster callout (≥50% of the
book in one sector across ≥3 names → "functionally one bet"). Position discipline: engine
sizing implies a max weight (Core ≤~8% · Standard ≤~5% · Starter ≤~2.5% · Lottery ≤~1%);
holdings past 1.25× the cap are flagged — "engine says Starter, you're holding 11%".
calendarEvents rides the existing Yahoo quoteSummary call (zero extra requests)→ next-earnings date on every monitor/screener card ("the signal predates the print"), a
"Reporting this week" strip on Home, and a decide-before-the-print Weekly Review item.
decision (logged with a timestamp in the browser's review history); the review reads
"N of M decided" until every line has one.
a returning visit (≥6h gap) opens with "since your last visit: X signal changes, Y downgrades".
saneQuote): any quote >40% away from its own previous close is droppedbefore it touches the tape (UI falls back to the engine price; the next poll self-heals).
node golden.js): fixed mock inputs must reproduce golden.json'sgrades/composites/signals exactly — a refactor can never silently change what an "A" means.
Intentional methodology changes re-baseline with --update and say so in the commit.
lib/funds.js, web/src/sectors.jsAn ETF is exposure to what it holds. Until this change a fund resolved its sector the way a
stock does — SECTOR_BY_TICKER[t] || assetProfile.sector || 'Other' — and Yahoo's
assetProfile is empty for funds, so every ETF landed in Other, which all three
portfolio surfaces (Portfolio, Exposure, Weekly Review) correctly exclude from
sector-concentration checks. A book that was 60% energy ETFs read as "60% unclassified" and
tripped nothing.
Holdings are now classified in one place, and each carries a sectorKind alongside its
sector:
| kind | means | counts toward the sector guardrail? |
|---|---|---|
sector | one economic risk — a sector (XLE → Energy), an industry (SMH → Semiconductors), a theme (ICLN → Clean energy), a country (FXI → China), a metal (GLD → Precious metals), crypto | yes |
diversified | spread across sectors by construction — broad market (SPY, VTI, QQQ), style/factor/dividend tilts, diversified international (VEA, EEM), bonds, broad commodity baskets | no — but labeled honestly, and still counted in allocation weights |
unclassified | a genuine data gap. The only thing that still shows as "Other" | no |
it is chartered to hold. There are no fabricated per-sector weight vectors anywhere in
the table — a fund contributes its whole weight to one bucket, or it is labeled diversified.
SOXL are Semiconductors; XBI is Healthcare; KRE is Financials; ICLN is Clean energy. Only a
fund whose mandate is genuinely sector-wide takes the sector bucket, and the coarse
"Technology" mandates (XLK, VGT, IYW) fold onto Software exactly as YAHOO_SECTOR_MAP
already folds GICS Technology for stocks outside the screened universe — one vocabulary, or
the Exposure page double-counts the same risk under two labels.
SMH; TQQQ, being 3× a broad index, stays diversified.
fetchFundExposure, cached 12h), inthis order:
1. The fund's own stated industry, read from its name and Yahoo categoryName
(industryFromName). Yahoo's holdings data speaks only coarse GICS, so a semiconductor
fund it can describe only as "technology" would otherwise land in Software. The issuer's
own charter is more precise than an 11-way sector split, and word boundaries there are
load-bearing — "Intermediate Core Bond" contains "media", "Goldman Sachs" contains
"gold". Deliberately ambiguous words ("gaming", "natural resources") match nothing and
fall through rather than guess.
2. Measured holdings — topHoldings.sectorWeightings, when the name claims no industry.
The bar is 80%, not a bare majority: a chartered sector fund holds 90–100% of one
sector, while a broad growth index merely leans tech-heavy. At 50% the first production
probe put VOOG (Vanguard S&P 500 Growth) in Software, which would have inflated a
user's tech concentration with a core index holding.
3. Otherwise diversified, with categoryName picking which honest label to show.
A throttle returns "unknown" rather than hardening into "not a fund".
never disagree except where the exception is documented with its reason (KWEB is filed under
China because country risk dominates its internet mandate; the ARK funds are held as one
Innovation bet).
XLE + XOP + OIH at 61% now reads "functionally one Energy bet" and tripsthe sector guardrail, while 70% in SPY correctly trips the position rules and not the
sector one.
benchmark, and the QUALITY of the result (largest contributor's share of the move; ≥60% is
called out as narrow). Labeled "interpretation — moderate confidence" with its basis.
wm_policy, 2026-07-17): the concentration threshold (default 25%),sector cap (40%), and per-sizing weight caps are user-editable — on the Exposure page, or
via the CHANGE POLICY action on a decision card (old → new value logged with a required
rationale). Every consumer (review triggers, discipline flags, holdings warnings) reads the
same store, and each rule displays its provenance ("your policy, set DATE" vs "app default").
Cause (appreciation vs trading, attributed from the prior review's stored prices+shares —
recorded from 2026-07-16 onward) / estimated Impact (20%-decline scenario × weight) /
Evidence / a decide-by date. Actions: KEEP · TRIM · ADD · SNOOZE (+ CHANGE POLICY on policy-backed items). Everything except snoozing
or dismissing an informational note requires a written rationale, logged with a timestamp.
web/src/thesis.js): a thesis = core argument (yours) + **five measurableassumptions** the engine re-checks from live detail — demand growth (≥ baseline−5pp), margin
durability (≥ baseline−3pp), valuation support (price below modeled fair value), price trend
(200-day), return on capital (≥9%). Missing data = "not measurable", never a strike.
Weakening always decomposes ("2 of 5 weakened: margin durability, valuation support"),
confidence = intact share (High/Moderate/Watch/Weak), evidence history appends weekly,
invalidation = kill criterion + ≤2 intact at two consecutive reviews.
(pp), dollar value, and per-holding contribution (thesis-tagged); gold baseline ticks mark
weeks with logged decisions or holdings changes. Constant current shares throughout —
return-excluding-deposits is NOT computable (no dated cash flows) and the page says so.
actual vs a gold target tick, drift in points, change since the last review, the dollar amount
a rebalance moves, and cause attribution ("~71% of this week's movement came from NVDA
appreciation — holdings unchanged → drift is price, not trading").
card (with a callout when ≥25% of capital has no written thesis) and a Return by thesis
rollup under the contribution view — is the return coming from the ideas you believe in?
5-session return) / Thesis / Conviction (grade + coverage) / Fair-value gap / Next catalyst /
Status (reason on hover + in the expanded row); price/day/sector demoted behind column
presets (Analysis / Trading / Everything) and per-column checkboxes (persisted); text +
status filters saveable as named chips; sticky header + first column; persisted density;
expandable rows; keyboard navigation (↑↓ move, Enter expands, Esc collapses).
(CAPM discount) vs model default (terminal rate, horizon); generated insights carry a
confidence tag and the data they're based on. Currency: all figures USD as reported —
no FX conversion is performed (disclosed in the data-trust line; ADR home-currency artifacts
are guarded in the DCF rather than converted).
The Weekly Review is the product's spine, not a Portfolio tab. Navigation (**consolidated
2026-08-11 from nine top-level destinations to six): Overview → Weekly Review (Weekly
Review · Decision History) → Portfolio (Holdings · Exposure · Theses) → Research → Track Record
(Signal Ledger · Backtest · Factor Lab · System Health) → Learn**, with Help in the rail footer,
the Settings panel and the data-line. Research now holds every research lens under three
sections — Screens (Best Overall · Screener · Midcaps · Radar · Most Upside · Quality Value
· Parabolic · ETFs), Models (Options · Sandbox) and Briefing (Macro · This Week · Social
· Hype Check) — picked with a section switch, so one mode's four-to-eight lenses are on screen
instead of all fourteen. Nothing was removed; three container groups became one.
7g-c. A briefing states its provenance instead of citing sources (2026-08-12) →
BriefingProvenance.jsx. Macro, This Week and Hype Check are written by a language model from
live web search. The obvious fix — ask the model for source URLs per claim — was **rejected
deliberately**: a model asked to cite will sometimes return a plausible link that does not
support the sentence attached to it, and on a page about money a citation that looks
checkable but is wrong is worse than none, because nobody verifies every link. What is stated
instead is all verifiable:
and carry their own timestamps, and each surface names which of its content is which;
Freshness is an explicit state, not a date to interpret: current (generated inside its
refresh window), updating (being rewritten, or refreshed late — the page says so and keeps
polling), unavailable (nothing current; the app shows nothing rather than something stale).
The block renders in the unavailable state too — that is precisely when a reader wonders what
the page is and whether any of it can be trusted.
The builder replaces its own introduction (2026-08-12) → ThesesView.jsx. "Build your
first thesis" opened the six-step wizard underneath the card explaining the six steps, which
was rendered on "no theses exist yet" without regard to whether the builder was running. The
explanation still filled the screen, step 1 sat below the fold, and the click read as though
nothing had happened. The introduction now yields the moment the thing it introduces is on
screen; the builder scrolls itself into view on open and on every step change; progress shows
as six marks rather than only a numeral; and the way out is labelled Exit builder rather
than "Cancel", because nothing is saved until step 6 — it leaves a workflow, it does not
destroy work.
Destructive controls name what they delete, and ask (2026-08-11) → Portfolio.jsx.
Removing a holding was a bare "×" — announced by a screen reader as "times", with no object —
and one click permanently deleted the position, its cost basis and its share history from the
only place they exist. Each control now carries aria-label="Remove NVDA holding" and confirms,
naming what is lost. The transaction-delete does the same and says that weekly/YTD returns are
computed from that ledger. The saved-filter chip was a clickable <span> inside another
clickable <span> — no role, no tab stop, mouse-only — and is now two sibling buttons (never
nested: a button inside a button is invalid, and is what the e2e nesting guard looks for).
Small explanatory type (2026-08-11). The review asked for more legible small grey text.
Measured first: --text-muted renders at 5.0–5.4:1 in light and 6.3–8.5:1 in dark
against its actual backgrounds — AA-passing in both themes, so contrast was not the problem.
Size was: the one-line summary under every collapsed card was hard-coded to 12px inline in
seven components, below the 13/14px the surrounding prose uses. It is now one class on the same
scale (13px), a step below its siblings so the card head still leads.
The page must never refute itself (2026-08-14) → computeBreadth in lib/scoring.js,
groundMacro in lib/editorial.js. Two ways the Macro page contradicted its own measured
data, both closed. Breadth: the per-sector loop counted names with unknown trend (the
warm bootstrap scores fundamentals before any price history exists) in the denominator while
the numerator required known-true — a cold cache rendered every sector as a confident, red
"0% above trend" beside an aggregate reading "— of —". Unknown trend is now excluded from
every denominator; a sector states a percentage only with ≥5 trend-known names; a zero
denominator renders "Breadth data unavailable" (never 0%), and registers a failing
breadth health subsystem — missing data looks missing all the way up. Narrative: the AI
tape and prose were whatever the model's web search said, so "Nasdaq modestly lower" shipped
beside a live +0.54% chip. The macro writer now receives the app's own measured index moves
(fresh-stamped quotes only), the tape values are rewritten from those measurements
(numbers are ours to state), a prose direction word about an index that contradicts the
measured move fails the generation (keep-last-good serves), and the client shows a
"market has moved since this was written" note if a later reversal outdates a true narrative.
The prose check is clause-aware: a direction word only counts while the index is still the
clause subject, so "the Russell 2000 while mega-caps lag" does not charge "lag" to the
Russell (a live false positive on 2026-08-14 killed a correct generation before the window
was cut at subject-switching conjunctions).
System Health now lists seven named data pipelines (quotes, fundamentals, technicals,
breadth, options, AI briefing, ledger), each rolled up to its worst member.
Hype grounding v2 (2026-08-14) — qualitative multiple claims are numeric claims in a
trench coat: "depressed / below-market multiple" at 35.1× against the 29× sector median is a
contradiction, not color. validateHype now checks cheap/rich multiple language
sector-relatively (medians computed from the app's own screener rows) and drops
contradicting cards; a claim with no coverage to check keeps the card but its rating becomes
"Insufficient evidence" — a paid product says "unchecked," it does not ship a confident
label. Every kept card carries a structured metrics strip (our P/E, the sector median, fwd
growth — the prose argues, these state) and normalized sources: publisher, **clickable https
URL**, publication date, and the retrieval time, on Hype and This Week alike.
Required means adopted (2026-08-14) — "Action required" in the Weekly Review is reserved
for a limit the user adopted (policy.setAt) or a broken trend rule, exactly as the Help
page defines it. An app-default guardrail exceeded is "Review suggested" — the sample
portfolio used to open on a red "Action required" for a 25% default the visitor never
adopted, beside copy correctly reading "default guardrail."
The briefing has a spend governor (2026-08-13) → lib/editorial.js. Every AI-written
section bills real money (model tokens + web searches, ~$0.10–0.15 per generation), and the
bill hit ~$10/day from three multipliers: 41 deploys in a week each regenerating all three
sections at boot, a scheduler that regenerated over fresh results, and failure loops where a
timed-out or unparseable call still billed a full generation and retried minutes later. Three
structural controls now bound it: the writer timeout is 300s (was 240s — successful
generations measure 200–240s+, so the old cap converted money into billed failures), each
generation gets 4 web searches (was 5, briefly 3 — search results are injected into the
prompt and dominate billed input, but at 3 the writers starved in production, narrating the
limit instead of emitting JSON or running to timeout; 4 is the measured reliability floor,
and the prompts now accept 6-row slates so a capped research pass always has a legal
output), and a hard per-ET-day cap (EDITORIAL_DAILY_CAP, default 12
generations across macro / This Week / Hype / theses; 0 disables) denies anything past the
ceiling before the API call — the deny is free, keep-last-good serves yesterday's cards, and
/api/health.editorial reports used / denied / cap. The macro section also joins the
keep-last-good + freshness-skip rules the row sections got (its own shape test, macroUsable
— a failed refresh previously replaced a good macro with the placeholder). Steady state:
~6 scheduled generations a day ≈ $1–1.50.
A failed generation never destroys a good briefing (2026-08-13) → bgKeepOnFailure /
bgIsFresh in lib/editorial.js. The night after the validation shipped, the briefing went
blank: a good 7-card Hype result was cached; the boot warm-up regenerated over it 6 minutes
later (refresh didn't check freshness — a wasted Claude call); that second call timed out, and
the failure envelope replaced the good cards. Now a live-authored result inside its TTL is
never regenerated, and a failed generation keeps the last-good cards with the error stamped
beside them — the empty envelope serves only when there was never anything good to keep. The
validation lookup also moved into the library (hypeLookupFromRows): built inline it read
r.pe while the screener keeps pe at r.detail.pe, so the validator dropped every
claim-bearing card as "unverifiable" while looking alive — the same wrong-path class as the
2026-08-06 beta metric, and the reason it is now a tested export fed real-shaped rows. Hype
generation also defers (cheaply, before any model call) until the screener cache it validates
against is actually filled.
Text, host and indexing hygiene (2026-08-12) — StockTwits text entity-decodes server-side
before truncation (a slice can bisect an entity and ship a dangling &am); the AI-written
This Week / Hype cards each carry 1–3 publisher names as per-card sources (names, never URLs —
the briefing's no-per-sentence-citation policy stands, per-card provenance is the floor); every
app view sets a route-specific document title; the SPA shell served for app routes carries
X-Robots-Tag: noindex (one shell, one canonical — the routes were indexable duplicates of the
homepage) and robots.txt disallows /app/; page routes on weekly-monitor.onrender.com 301 to
the apex so no reader accrues a second localStorage there, while /api/* stays reachable on
that host by design (the health workflows probe it directly); and every in-app mention of the
methodology now links to the /methodology page (three surfaces said "see METHODOLOGY.md",
a file inside a private repo).
One time basis for "today" (2026-08-12) → web/src/marketDay.js. Yahoo's daily history
carries the in-progress session as a trailing, still-moving bar, and the Overview read that bar
as "prior close": at 3 PM it labelled a delayed live price "as of Aug 12 close" and showed a
−$16 feed drift while the quote-based Portfolio said Today +$222. Both were internally
consistent; they answered different questions. The shared vocabulary now: prior close is
the quote's own official prior close (c − d), identically on every view, so the hero's
"prior + since = current estimate" equals the Portfolio's Today by construction; **multi-session
stats** ("this week", YTD, beta/vol windows) use only completed bars — a bar counts as a
close when its ET date is past, or is today and the session has ended; and the date on
"as of … close" is found by matching the quote's prior close against completed bars (a
calendar rule mislabels the day after any market holiday; in the evening the provider still
measures the finished session, so the anchor stays yesterday until the next open). Charts may
draw the live point — a line into "now" is honest; what no surface may do is present the
current session's partial bar as an official close.
The Overview warms the portfolio group (2026-08-12) → fetchPortfolioCached in api.js,
warmPortfolio in prefetch.js. Portfolio, Weekly Review, Exposure and Theses each called
/api/portfolio from scratch on mount — the same request four times over one walk through the
app. It is now one request-level memo (the fetchHistoryCached pattern applied to grades):
callers share the in-flight promise, a usable answer is kept for 90 seconds — shorter than
every caller's own refresh cadence, so this changes where a response comes from on navigation,
never how fresh data is allowed to be — and a starved or failed answer expires immediately.
That last rule is the load-bearing one: every caller has a retry loop built on gradesUsable(),
and a memo that held a throttled 200-with-null-grades for even a few seconds would make those
loops spin against the cache and banner an outage that isn't happening. The Overview fires this
one request ~1.2s after it paints (an empty book warms nothing), and everything else the
portfolio group needs is already warm there: the Overview fetches every holding's history for
its own Weekly Brief, and Best Overall reads the monitor payload App polls from mount. Measured
end to end: Overview → Holdings → Weekly Review is exactly one portfolio request.
Navigation keeps what it already knows (2026-08-11) → usePolling.js, prefetch.js. Every
view began at data: null, so leaving a list and returning re-fetched from scratch and showed a
placeholder for the round trip — verified on production, where a revisit issued a fresh request
every time. Two changes, both in the shared polling hook rather than per view:
cacheKey paints its lastusable payload on the first render and revalidates in the background, so a navigation costs
nothing and a transient failure (a throttle, a {status:'computing'} answer) shows the
previous answer instead of an empty box. Deliberately not persisted: data that survived a
reload would be a different and much worse promise than "what you were just looking at".
Portfolio has no key on purpose — its fetcher varies with the holdings list, and one key
would serve one book's grades under another's.
happens before the click rather than after it. Bounded by attempts, not cache hits: an
endpoint answering {status:'computing'} is never cached, so a cache-only guard fired a fresh
request on every pass of the pointer (measured: two hovers, two requests) at an app that
rate-limits per IP. One attempt per key per session; the view's own polling is the retry path.
Worth recording about the diagnosis: the reported "2–3 seconds of empty skeletons" did not
reproduce as server latency — asked directly, the endpoints answer 200 in ~100ms with full data,
and Midcaps painted in ~130ms. The multi-second waits reproduced only when rapid navigation
tripped the app's own rate limiter, after which the hook waits a full pendingMs (5s) before
retrying. The re-fetch-on-every-visit defect was real and is what these changes fix.
The Overview is a decision cockpit (2026-08-11) → HomeView.jsx, MarketPulse.jsx. It was
carrying onboarding, reporting dates, portfolio status, index levels, the yield curve, breadth,
sector momentum, idea tiles, options and the signal ledger. It now answers four questions and
only those: what happened to the book this week (the Weekly Brief) → what needs deciding
(decisions due, required actions, changes since your last visit, prints this week) → **how the
book is positioned (the condition strip) → what to do next** (the review call-to-action).
sector heat strip now front Briefing → Macro as MarketPulse, beside the written market
read they belong with. It renders above the narrative and outside every early return, which
also fixes a quieter problem: with no Anthropic key that page used to be nothing but an
apology banner, and now the deterministic half is always there.
views — Best Overall, Radar, Options — that the consolidated navigation reaches in one click,
so on the landing page they were competing with decisions for the top of the screen. Nothing
became unreachable.
on the page the app opens on.
view of every visit; the label still has to be unmistakable, so it is gold and persistent, but
the explanation moved to its tooltip and Exit still confirms before clearing what was tried.
journeyProgress/dismissJourney)— verified, not changed.
An expanded analysis gets the whole row (2026-08-11) → .card-open in styles.css,
useAnchoredOpen in collapse.js. The list grid is repeat(auto-fill, minmax(310px, 1fr)) —
three columns on a desktop — so opening a card crammed a full tear-sheet (DCF waterfall, factor
bars, charts, the scoring explainer) into ~310px with two empty columns beside it. An open card
now spans the row. Two consequences had to be handled rather than assumed:
characters per line; comfortable reading is 45–85. Prose now carries a measure cap while
tables, factor bars, charts and contract ladders keep the full row. The cap is expressed in
ch, but ch is the width of "0" — wider than the average letter — so a 74ch cap actually
rendered ~94 characters; it was tightened against the rendered text, not the unit, and the
e2e assertion measures real characters against each element's own font for the same reason.
own — measured 173px, out from under the cursor. useAnchoredOpen records the card's
position, and in a useLayoutEffect (after the DOM updates, before paint) scrolls by exactly
the delta, so the card stays visually still while the list rearranges around it.
On a phone nothing changes: there is only ever one column to span.
Every view has an address (2026-08-11) → web/src/routes.js. The app previously lived
entirely at /: Research, Help and an expanded stock analysis were the same URL, so nothing
could be bookmarked or shared, browser Back left the site instead of stepping back a view, and
a reload dropped you wherever localStorage last remembered rather than where you were.
/app/<section>[/<lens>] — e.g. /app/research/quality-value, /app/portfolio/exposure, /app/track-record/signal-ledger. /app/stock/<TICKER> addresses one analysis;
/app/learn/<topic> an in-app Learn topic.
(own, deep, para, mon), so the URL carries a readable slug and routes.js is the only
place the two vocabularies meet — keys can be renamed without breaking a link already shared.
/app prefix exists to prevent collisions, not for tidiness. The server renders real pages at /welcome, /learn, /learn/<slug>, /methodology, /terms and /privacy and
matches them before its SPA fallback, so an unprefixed route would be shadowed the moment a
nav slug matched a public one — and learn already does, being both a public SEO page and an
in-app section. Prefixing removes the class of bug instead of maintaining a blacklist. No
server change was needed: the fallback already serves index.html for anything unmatched, and
the service worker treats navigations network-first with the cached shell as offline fallback.
A shared link that showed the recipient their last screen would be a lie. Landing on /
stamps the canonical address of whatever is shown, with replaceState so no phantom entry
sits between the app and wherever the reader came from.
not 404 a link someone already sent. An unknown section, a malformed escape, or a bogus
ticker falls back to the Overview.
Track Record stays top-level deliberately, against the suggestion to file it behind a secondary
menu: the ledger and the backtest are the app's evidence about itself, and the same review asks
the product to be more candid about how provisional the entry signal is (§1c) — burying the
record would work against that. Old bookmarked groups (models, briefing, system, and the
pre-2026-07 keys) migrate automatically, carrying the remembered sub-view with them, so someone
who left the app on Briefing → Hype Check returns to Research → Hype Check. The Overview orders sections by decision priority:
weekly conclusion → decisions requiring review (with a start/continue call-to-action) →
portfolio condition → market context (breadth and the yield curve deliberately do not outrank
policy violations). The decision queue is a guided wizard — one consolidated decision at a
time ("Decision 2 of 6"), related flags on the same name merged into a single resolution, the
remaining queue in a compact sidebar, "Show all" for the full-list view. Sizing alerts are
tiered (sizingTier): Critical policy breach (>3× the cap) / Review sizing (>1.5×) /
Monitor (>1.15×) / silence when in line — the full explanation lives behind the tooltip, so red
is reserved for what's actually critical. Theses open with a guided first-thesis onboarding
(argument → drivers → risks → assumptions → invalidation → cadence) instead of empty cards; the
empty Signal Ledger shows an illustrative example table plus the next snapshot time. Privacy
is stated unmistakably where it matters: *personal holdings and decisions stay local to the
browser; the public ledger contains only the engine's signals — never portfolio data.* Type
scale: body ~14px, decisions 14–15px, controls ≥32px tall, secondary-text contrast raised.
Two ways in, both engineered so holdings never leave the device — the security is
structural, not a promise:
web/src/csvImport.js): export a positions CSV from any major broker(Fidelity / Schwab / Vanguard / IBKR / Robinhood / Webull); the file is read via FileReader
and parsed in the browser — there is no upload endpoint. Header-alias detection (not
per-broker templates), quoted-cell CSV parsing, cash/money-market rows skipped (specific
patterns — Fidelity's account-type "Cash" column famously false-positives naive filters),
BRK.B → BRK-B normalization, duplicate lots merged share-weighted, preview with per-row
checkboxes, then merge or replace.
web/src/cryptoCode.js): the cross-device code can bepassphrase-encrypted — PBKDF2-SHA256 (200k iterations, random salt) → AES-256-GCM (random
IV), WebCrypto in-browser. GCM authenticates, so a wrong passphrase or tampered code fails
loudly instead of loading garbage. Format WMPF2.b64(salt|iv|ct); legacy plaintext WMPF1
still imports. Passphrases are never stored and not recoverable.
holdings through a server and third party, breaking the local-only guarantee, and adds
accounts + per-connection cost. It's a product decision, not a default.
| Source | Used for | Key |
|---|---|---|
| Yahoo Finance | prices, history, fundamentals, ETF holdings | keyless (cookie/crumb) |
| CBOE (delayed) | option chains + greeks | keyless CDN |
| Finnhub | analyst ratings/consensus | free tier |
| StockTwits | social sentiment | keyless |
| Anthropic Claude | Macro / This Week / Hype editorial, one-line theses | ANTHROPIC_API_KEY |
Grades/scores work fully on the keyless sources; the Claude layer is editorial only. Nothing is invented — a
missing input drops out of the average rather than being guessed.
Data-quality guards (2026-07-21, → sanitizeFundamentals, bounds in Q_BOUNDS): a field can be present
but wrong — unit confusion (a percent read as a fraction is 100× too big), stale pre/post-split analyst
targets (rejected when outside ⅛×–8× of price), float-inflated insurer FCF yields (>60% of market cap),
negative-EBITDA leverage ratios (|x|>50), >100% institutional "ownership" from double-counted 13Fs (>150%),
NaN/∞, negative share counts. Impossible values are rejected at ingest against deliberately wide bounds —
unusual-but-real readings (a trough-base +1369% EPS rebound) pass. Order is the contract: **sanitize →
record → backfill** — a rejected value can never enter the last-known-good cache, and the two-tier warm-fill
replaces it with the last good value (the card then carries the gold "fundamentals as of… · last-good"
freshness stamp). Reject counts are public on /api/health (quality.rejects, per field).
Kept in sync with server.js. If a weight/threshold here disagrees with the code, the code is the source of truth — update this file.