2 Commits

Author SHA1 Message Date
Elie Habib
fffc5d9607 fix(analyze-stock): classify dividend frequency by median gap (#3102)
* fix(analyze-stock): classify dividend frequency by median gap

recentDivs.length within a hard 365.25-day window misclassifies quarterly
payers whose last-year Q1 payment falls just outside the cutoff — common
after mid-April each year, when Date.now() - 365.25d lands after Jan's
payment timestamp. The test 'non-zero CAGR for a quarterly payer' flaked
calendar-dependently for this reason.

Prefer median inter-payment interval: quarterly = ~91d median gap,
regardless of where the trailing-12-month window happens to bisect the
payment series. Falls back to the old count when <2 entries exist.

Also documents the CAGR filter invariant in the test helper.

* fix(analyze-stock): suppress frequency when no recent divs + detect regime slowdowns

Addresses PR #3102 review:

1. Suspended programs no longer leak a frequency badge. When recentDivs
   is empty, dividendYield and trailingAnnualDividendRate are both 0;
   emitting 'Quarterly' derived from historical median would contradict
   those zeros in the UI. paymentsPerYear now short-circuits to 0 before
   the interval classifier runs.

2. Whole-history median-gap no longer masks cadence regime changes. The
   reconciliation now depends on trailing-year count:
     recent >= 3  → interval classifier (robust to calendar drift)
     recent 1..2  → inspect most-recent inter-payment gap:
                    > 180d = real slowdown, trust count (Annual)
                    <= 180d = calendar drift, trust interval (Quarterly)
     recent 0     → empty frequency (suspended)
   The interval classifier itself is now scoped to the last 2 years so
   it responds to regime changes instead of averaging over 5y of history.

Regression tests:
- 'emits empty frequency when the dividend program has been suspended' —
  3y of quarterly history + 18mo silence must report '' not 'Quarterly'.
- 'detects a recent quarterly → annual cadence change' — 12 historical
  quarterly payments + 1 recent annual payment must report 'Annual'.

* fix(analyze-stock): scope interval median to trailing year when recent>=3

Addresses PR #3102 review round 2: the reconciler's recent>=3 branch
called paymentsPerYearFromInterval(entries), which scopes to the last
2 years. A monthly→quarterly shift (12 monthly payments in year -2..-1
plus 4 quarterly in year -1..0) produced a 2-year median of ~30d and
misclassified as Monthly even though the current trailing-year cadence
is clearly quarterly.

Pass recentDivs directly to the interval classifier when recent>=3.
Two payments in the trailing year = 1 gap which suffices for the median
(gap count >=1, median well-defined). The historical-window 2y scoping
still applies for the recent 1..2 branch, where we actively need
history to distinguish drift from slowdown.

Regression test: 12 monthly payments from -13..-24 months ago + 4
quarterly payments inside the trailing year must classify as Quarterly.

* fix(analyze-stock): use true median (avg of two middles) for even gap counts

PR #3102 P2: gaps[floor(length/2)] returns the upper-middle value for
even-length arrays, biasing toward slower cadence at classifier
thresholds when the trailing-year sample is small. Use the average of
the two middles for even lengths. Harmless on 5-year histories with
50+ gaps where values cluster, but correct at sparse sample sizes where
the trailing-year branch can have only 2–3 gaps.
2026-04-15 14:00:57 +04:00
Elie Habib
2b189b77b6 feat(stocks): add dividend growth analysis to stock analysis panel (#2927)
* feat(stocks): add dividend growth analysis to stock analysis panel

Shows yield, 5Y CAGR, frequency (quarterly/monthly/annual), payout
ratio, and ex-dividend date. Hidden for non-dividend stocks. Data
from Yahoo Finance dividend history.

* fix(stocks): bump cache key + fix partial-year CAGR + remove misleading avg yield

* fix(stocks): hydrate payout ratio, drop dead five-year yield, currency-aware dividend rate

- Add fetchPayoutRatio helper that calls Yahoo quoteSummary's summaryDetail
  module in parallel with the dividend chart fetch and returns the raw 0-1
  payout ratio (or undefined when missing/non-positive). The chart endpoint
  alone never returns payoutRatio, which is why it was hardcoded to 0.
- Make payout_ratio optional in proto and DividendProfile so a missing value
  is undefined instead of 0; remove five_year_avg_dividend_yield entirely
  (proto reserved 51) since it was always returned as 0 and never wired up.
- StockAnalysisPanel.renderDividendProfile now omits the Payout Ratio cell
  unless the value is present and > 0, formats it as (raw * 100).toFixed(1)%,
  and renders the dividend rate via Intl.NumberFormat with item.currency so
  EUR/GBP/JPY tickers no longer get a hardcoded "$" prefix.
- Bump live cache key v2 -> v3 so any cached snapshots persisted with the
  old shape are refetched once.
- Tests cover: payoutRatio populated from summaryDetail, payoutRatio
  undefined when summaryDetail returns 500 or raw=0, dividend response
  shape no longer contains fiveYearAvgDividendYield.

* fix(stocks): bump persisted history store to v3 to rotate pre-PR snapshots

Live analyze-stock cache was already bumped to v3, but the persisted
history store (premium-stock-store.ts) still used v2 keys for index/item
lookups. Pre-PR snapshots without the new dividend fields could pass the
age-only freshness check and suppress a live refetch, leaving the new
dividend section missing for up to 15 minutes.

Bumping the persisted store keys to v3 makes old snapshots invisible.
The data loader sees an empty history, triggers a live refetch, and
repopulates under the new v3 keys. Old v2 keys expire via TTL.

* fix(stocks): compute dividend CAGR correctly for quarterly/semiannual/annual payers

Previously computeDividendCagr() required at least 10 distinct dividend
months for a year to count as "full", which excluded every non-monthly
dividend payer (quarterly = 4 months, semiannual = 2, annual = 1).
CAGR therefore collapsed to 0/N/A for most ordinary dividend stocks.

The new check uses calendar position: any year strictly earlier than the
current calendar year is treated as complete, and the current in-progress
year is excluded to avoid penalizing stocks whose next payment has not
yet landed.

* test(stocks): pass analystData to buildAnalysisResponse after rebase onto #2926
2026-04-11 16:27:51 +04:00