Weekly Monitor

Weekly Monitor — Methodology (Audit Trail)

*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.


1. Monitor grade (Best Overall / Quality Value / Parabolic) → scoreTicker

A 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.

ComponentFunctionHow it's scored
ValuevalueScoreBlend 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.
TrendtrendScoreAbove 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/timingentryScoreBase 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.
RatingratingScoreAnalyst consensus skew net = (buy−sell)/total; base 50 + net×45. Small nudge for target upside, capped at [−15, +20].
QualityqualityScoreWeighted: 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:

Listvaluetrendentryratingqualitycurve
Own0.250.220.180.180.170
Quality Value ('deep')0.400.180.120.150.15+3
LEAPS0.280.280.220.120.100
Parabolic0.380.180.180.160.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.

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.


1b. Sector profiles → 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.

ProfileDCF growth cap / terminalKey tiltsQuality lens
Semiconductors20% / 2.5%trend ×1.15, entry ×1.10standard
Software30% / 3.0%quality ×1.15ROIC-tilted (.45/.25/.30)
Internet platforms30% / 3.0%quality ×1.15, rating ×1.05ROIC-tilted
AI infrastructure30% / 2.5%, r ≥ 9%trend ×1.20, quality ×0.85standard
Payments25% / 3.0%quality ×1.15ROIC-tilted
Healthcare25% / 2.5%rating ×1.20standard
Industrials15% / 2.5%quality ×1.10, value ×1.05standard
Consumer15% / 2.5%entry ×1.10standard
Energy8% / 2.0%quality ×1.20, trend ×1.15, value ×0.80debt-tilted (.35/.40/.25)
Materials10% / 2.0%quality ×1.15, trend ×1.10debt-tilted
FinancialsDCF vetoedrating ×1.15, value ×0.85ROE vs cost of equity only (debt/FCF parts dropped — meaningless for balance-sheet businesses)
REITs6% / 2.0%entry ×1.05debtShift +3 (5× EBITDA is normal), FCF-tilted
Telecom & media8% / 2.0%value ×1.05debtShift +1.5, FCF-tilted (.30/.30/.40)
Autos10% / 2.0%trend ×1.10, quality ×1.10debt-tilted
Utilities6% / 2.0%quality ×1.10, entry ×1.05debtShift +2
Generalist (default)30% / 2.5%nonestandard (.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_MAPdefault (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.


2. DCF intrinsic value → computeDCF

Our own 5-year discounted-cash-flow model — this, not FMP, is what the app's "DCF" means.

5 years — the cap and terminal rate come from the sector profile (§1b); the generalist default is 30% → 2.5%.

r = clamp(8%…12%, rf + β* × 4.7%) where rf is the live 2-year Treasury CMT (FRED H.15, refreshed at least daily; /api/healthriskFree 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.

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.


3. Screener composite (1,000-name universe) → scoreScreen, weights SCREEN_W

FactorWeightSource
DCF upside0.18computeDCF (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.
Technicals0.11trend/RSI/support
Revenue growth0.12Yahoo — 60% absolute + 40% sector-relative
Market/forward growth0.09Yahoo — 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 sheet0.12FCF yield 0.35 · leverage 0.30 · EBITDA margin 0.20 (60/40 blend) · gross margin 0.15 (sector-relative)
Growth quality0.12margin 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. momentum0.08analyst 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.
Valuation0.09PEG first → sector-relative EV/EBITDA (no-PEG fallback) → P/E band
Price momentum (12-1)0.09momentumFactor — 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.


4. LEAPS opportunity score → scoreLeap, weights LEAP_W

Hunts 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:

ComponentWeightMeaning
Conviction0.18the stock's screener composite
Upside0.15stock'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.

unjudgeable, 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).


5. Most Upside (opportunity ceiling) → scoreUpsideName

Model-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.


6. ETF score (75-fund pool) → scoreETFRow

Weights: 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).


7. Black–Scholes projection (every stock card) → blackScholesBlurb, bsCall, _normCdf

From 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):

Abramowitz–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).

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.


7b. Radar (overlooked opportunities) → buildRadar, weights RADAR_W

Reuses 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.


7c. Model-context line → precisionAnalysis

Born 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.


7d. Factor Lab (walk-forward factor validation) → runFactorLab

The 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.


7e. Market Pulse (Home) → computePulse, endpoint /api/pulse

The 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.

7f. Signal Ledger → buildSnapshot / computeLedger, endpoints /api/snapshot /api/ledger

The accountability layer (2026-07-16): the engine's opinions are recorded, then graded.

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.

(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).

7g. Exposure, catalysts, and the decision ritual (2026-07-16)

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%".

→ 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".

before it touches the tape (UI falls back to the engine price; the next poll self-heals).

grades/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.

7g-b. Fund exposure — what an ETF holding counts as (2026-08-10) → lib/funds.js, web/src/sectors.js

An 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:

kindmeanscounts toward the sector guardrail?
sectorone economic risk — a sector (XLE → Energy), an industry (SMH → Semiconductors), a theme (ICLN → Clean energy), a country (FXI → China), a metal (GLD → Precious metals), cryptoyes
diversifiedspread across sectors by construction — broad market (SPY, VTI, QQQ), style/factor/dividend tilts, diversified international (VEA, EEM), bonds, broad commodity basketsno — but labeled honestly, and still counted in allocation weights
unclassifieda 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.

this 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 holdingstopHoldings.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).

the sector guardrail, while 70% in SPY correctly trips the position rules and not the

sector one.

7h. Decisions, theses, and portfolio layers (2026-07-17 — all client-side, stored in the browser)

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.

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.

assumptions** 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).

7i. The review-centered structure (2026-07-17)

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:

usable 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.

— 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/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.

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.

7j. Portfolio loading & security (2026-07-17)

Two ways in, both engineered so holdings never leave the device — the security is

structural, not a promise:

(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.

passphrase-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.

8. Data sources (all best-effort, gracefully degrading)

SourceUsed forKey
Yahoo Financeprices, history, fundamentals, ETF holdingskeyless (cookie/crumb)
CBOE (delayed)option chains + greekskeyless CDN
Finnhubanalyst ratings/consensusfree tier
StockTwitssocial sentimentkeyless
Anthropic ClaudeMacro / This Week / Hype editorial, one-line thesesANTHROPIC_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.

See it run on live stocks →Every grade in the app links back to the section that defines it. Nothing here is advice.

← Plain-English Learn libraryTerms · Privacy →