Date: 2026-06-29 Status: Implemented (2026-08-07, v1.10.0)
Implementation notes — two deviations from this design:
- The allocation maths moved to a new
src/allocation.tsthat takes config as an argument, withsrc/analyze.tsreduced to a wrapper that injects it. The design'stest/analyze.test.tswas impossible as written:analyze.tsimportsconfig.js, which readsconfig.jsonat import time and throws when absent — and CI runs without one. Tests live intest/allocation.test.tsagainst the pure module. - The design missed
src/telegram.ts, which has a weekly renderer parallel toweeklyEmail.ts. Its "Consider Trimming" list was sourced fromreport.itemswithgapPct < -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 itshasCrossCurrencycheck.
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, andsuggestedBuyValueall 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/SELLaction, 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.
- held-only ticker: present in
currentHoldings, absent fromtargetPortfolio, absent fromwatching. Examples in the user's config: AAPL, AMZN, INTC, TSM, MSFT. - A ticker present in both
targetPortfolioandcurrentHoldings(e.g. VOO) is a normal target holding and is unaffected. - A ticker in
targetPortfoliobut not held (e.g. SMH with 0 shares) stays a normal underweight target holding and is unaffected.
- Held-only tickers no longer appear in the daily AI recommendations or the daily email allocation table.
- 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.
- Portfolio value, beta, and dividend totals continue to reflect all real holdings, including held-only tickers.
- ETF overlap discounting against held-only tickers continues to work.
- No held-only ticker is sent to the AI (zero token cost on them).
- No sell-timing / SELL-signal logic is added. Richfolio stays buy-only.
currentHoldingsconfig 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/underweightsections are unchanged.
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 atargetPortfolioentry (today's behavior, minus held-only tickers).untrackedItems: AllocationItem[]— new. Held-only tickers (held, no target, not watching). Built with the identicalAllocationItemconstruction (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.
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.
The weekly report must keep showing held-only holdings, but without a TRIM/SELL action.
- Rebalancing action table: the
sortedsource becomesreport.itemsonly (held-only tickers removed from the action table → noactionLabel→ no TRIM/SELL). The existing.filter((i) => i.targetPct > 0 || i.currentValue > 0)can be simplified totargetPct > 0since allitemsnow have a target; keep behavior equivalent. - "Holdings Not in Target Portfolio" neutral list: change
noTargetto be sourced fromreport.untrackedItems(it currently filtersitemsfortargetPct === 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 filtertargetPct > 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.
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").
Unit tests for runAnalysis (new test/analyze.test.ts, pure function, no network —
construct a priceData map fixture):
- A held-only ticker (in
currentHoldings, not intargetPortfolio, not inwatching) lands inuntrackedItemsand not initems. - A ticker in both
targetPortfolioandcurrentHoldingsstays initems(not inuntrackedItems). - A
targetPortfolioticker with zero held shares stays initems. - A
watchingticker appears inwatchingItemsand in neitheritemsnoruntrackedItems. portfolioBetaandestimatedAnnualDividendinclude 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.
src/analyze.ts— splititems/untrackedItems, extendAllocationReport, fix beta/dividend loops.src/weeklyEmail.ts— re-pointnoTargettountrackedItems, restrict action table toitems, broadenhasCrossCurrency.test/analyze.test.ts— new unit tests.- (No change to
src/providers/prompts.ts,src/email.ts,config.json.)