*Last updated: 2026-07-27 (universe 1,000; honest caveats added 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.
| 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 composite + curve):
A ≥86 · A− ≥80 · B+ ≥74 · B ≥66 · B− ≥60 · C+ ≥54 · C ≥46 · else C−.
Signal (signal) — the rule the backtest said mattered: never buy support in a downtrend.
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%, 4.3% + β* × 4.7%) 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): the same machinery run backwards — 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 4.3% 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 those odds under 25% within a year.
scoreScreen, weights SCREEN_W| Factor | Weight | Source | |
|---|---|---|---|
| DCF upside | 0.18 | computeDCF (ours, CAPM-discounted §2) | |
| 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. The weight is published because the factor fires when keys/paths allow; the renormalization is the standard null-factor mechanic (§3 note below). | |
| 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 in this composite — only displayed alongside.
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) |
| 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 reaches fair value |
| 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 ITM / ~0.55 ATM / ~0.35 OTM).
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).
blackScholesBlurb, bsCall, _normCdfFrom each name's own annualized volatility (σ, from price history) over a 1-year horizon, risk-free rate RF_RATE = 4.3%:
price × e^(drift ± σ), where drift = r − σ²/2.N(d2) (standard normal via Abramowitz–Stegun).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 REVIEW 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), BUY-flip hit rate, 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).
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 — BUY must out-return WAIT must out-return REVIEW, 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 reaching the analyst target within a year" (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.
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: **Overview → Review
(Weekly Review · Decision History) → Portfolio (Holdings · Exposure · Theses) → Research (all
ranked lenses + ETFs) → Models (Options · Sandbox) → Briefing → System** — one nav system; old
bookmarked groups migrate automatically. 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.