Skip to content

Latest commit

 

History

History
155 lines (121 loc) · 7.94 KB

File metadata and controls

155 lines (121 loc) · 7.94 KB

Design: Drop held-only tickers from the daily brief and weekly TRIM

Date: 2026-06-29 Status: Implemented (2026-08-07, v1.10.0)

Implementation notes — two deviations from this design:

  1. The allocation maths moved to a new src/allocation.ts that takes config as an argument, with src/analyze.ts reduced to a wrapper that injects it. The design's test/analyze.test.ts was impossible as written: analyze.ts imports config.js, which reads config.json at import time and throws when absent — and CI runs without one. Tests live in test/allocation.test.ts against the pure module.
  2. The design missed src/telegram.ts, which has a weekly renderer parallel to weeklyEmail.ts. Its "Consider Trimming" list was sourced from report.items with gapPct < -1, so held-only tickers appeared there too. Without a change they would have vanished from the Telegram weekly entirely rather than moving to a neutral list. Added the same neutral "Not in target portfolio" line and broadened its hasCrossCurrency check.

Problem

Tickers that are in config.json currentHoldings but have no targetPortfolio allocation (and are not in watching) produce low-value output:

  • Daily brief: they always render as HOLD / WAIT ("X is N% overweight vs. 0% target — no allocation gap to close"). Richfolio is buy-only by design (the AI prompt, guards, and suggestedBuyValue all assume "close an underweight gap"), so there is no real sell logic behind these messages — they are noise.
  • Weekly rebalancing report: they always render with a TRIM / SELL action, because their allocation gap is negative (target 0% < current %). This is misleading — the user holds these deliberately and does not want a standing trim suggestion.

The user keeps these extra holdings in currentHoldings on purpose: to keep portfolio totals honest and to inform ETF overlap. They should remain configured, but stop generating buy/sell recommendations.

Definitions

  • held-only ticker: present in currentHoldings, absent from targetPortfolio, absent from watching. Examples in the user's config: AAPL, AMZN, INTC, TSM, MSFT.
  • A ticker present in both targetPortfolio and currentHoldings (e.g. VOO) is a normal target holding and is unaffected.
  • A ticker in targetPortfolio but not held (e.g. SMH with 0 shares) stays a normal underweight target holding and is unaffected.

Goals

  1. Held-only tickers no longer appear in the daily AI recommendations or the daily email allocation table.
  2. Held-only tickers no longer receive a TRIM/SELL action in the weekly report; they appear only in the neutral "Holdings Not in Target Portfolio" list.
  3. Portfolio value, beta, and dividend totals continue to reflect all real holdings, including held-only tickers.
  4. ETF overlap discounting against held-only tickers continues to work.
  5. No held-only ticker is sent to the AI (zero token cost on them).

Non-goals

  • No sell-timing / SELL-signal logic is added. Richfolio stays buy-only.
  • currentHoldings config is not changed; the user keeps their extra holdings.
  • The ETF overlap feature is not removed (it is pure math in analyze.ts, not an AI call, and costs no tokens).
  • The weekly report's overweight / onTarget / underweight sections are unchanged.

Design

1. src/analyze.ts — the one structural change

The item-build loop currently iterates targetPortfolio ∪ currentHoldings (minus watching) and pushes every ticker into a single items array. Split the output:

  • items: AllocationItem[] — tickers with a targetPortfolio entry (today's behavior, minus held-only tickers).
  • untrackedItems: AllocationItem[]new. Held-only tickers (held, no target, not watching). Built with the identical AllocationItem construction (same fields: price, currentPct, gapPct, P/E, beta, dividend, 52w, etc.) so downstream renderers can reuse them without special-casing.

Routing rule inside the loop, for each non-watching ticker with a quote: if the ticker has a targetPortfolio entry → items; otherwise (present only because it's held) → untrackedItems.

Add untrackedItems: AllocationItem[] to the AllocationReport interface and the returned object.

Aggregate accuracy: the portfolio-beta loop and the estimated-annual-dividend loop currently iterate items. Change both to iterate [...items, ...untrackedItems] so held-only holdings still count toward beta and dividend totals. totalCurrentValue is summed independently from currentHoldings and is unaffected. The ETF overlap discount reads currentHoldings directly and is unaffected.

2. Daily brief — no code change

src/providers/prompts.ts (AI prompt) and src/email.ts (daily recs filter + allocation table) iterate report.items. Once held-only tickers are no longer in items, they disappear from the AI prompt, the daily recommendations, and the daily allocation table automatically. No edits required in these files.

Consequence: held-only tickers are not sent to the AI at all → zero tokens spent on them.

3. Weekly report — src/weeklyEmail.ts

The weekly report must keep showing held-only holdings, but without a TRIM/SELL action.

  • Rebalancing action table: the sorted source becomes report.items only (held-only tickers removed from the action table → no actionLabel → no TRIM/SELL). The existing .filter((i) => i.targetPct > 0 || i.currentValue > 0) can be simplified to targetPct > 0 since all items now have a target; keep behavior equivalent.
  • "Holdings Not in Target Portfolio" neutral list: change noTarget to be sourced from report.untrackedItems (it currently filters items for targetPct === 0 && currentValue > 0). Renders ticker, value, and current % — no action verb. This is where MSFT et al. appear.
  • hasCrossCurrency: check across [...report.items, ...report.untrackedItems] so a cross-currency held-only ticker still triggers the FX footnote.
  • overweight / onTarget / underweight: already filter targetPct > 0; held-only tickers (targetPct 0) never matched these, so no change.
  • "On Target X/Y" stat: denominator report.items.filter((i) => i.targetPct > 0) is unchanged and still correct.

4. Known behavior change

npm run refresh -- MSFT on a held-only ticker will no longer produce a recommendation, because the ticker is no longer in report.items. The price fetch still works (allUniqueTickers() includes currentHoldings), but there is no allocation item to analyze. To get an opinion on a holding, move it to watching. Exact refresh handling will be confirmed during implementation; if it errors ungracefully, add a clear message ("MSFT is a held-only ticker with no target allocation — add it to watching for analysis").

Testing

Unit tests for runAnalysis (new test/analyze.test.ts, pure function, no network — construct a priceData map fixture):

  1. A held-only ticker (in currentHoldings, not in targetPortfolio, not in watching) lands in untrackedItems and not in items.
  2. A ticker in both targetPortfolio and currentHoldings stays in items (not in untrackedItems).
  3. A targetPortfolio ticker with zero held shares stays in items.
  4. A watching ticker appears in watchingItems and in neither items nor untrackedItems.
  5. portfolioBeta and estimatedAnnualDividend include the contribution of a held-only ticker (i.e. totals reflect held-only holdings, proving the aggregate loops iterate both arrays).

Run npm run typecheck and npm test.

Files touched

  • src/analyze.ts — split items/untrackedItems, extend AllocationReport, fix beta/dividend loops.
  • src/weeklyEmail.ts — re-point noTarget to untrackedItems, restrict action table to items, broaden hasCrossCurrency.
  • test/analyze.test.ts — new unit tests.
  • (No change to src/providers/prompts.ts, src/email.ts, config.json.)