Skip to content

v1.8.1 — Frozen-data guard now covers watch-list tickers

Choose a tag to compare

@furic furic released this 06 Aug 16:26
· 39 commits to main since this release

First patch release in the project's history. One fix, no new features, no config changes.

The frozen-data guard never applied to watch-list tickers

v1.8.0 shipped two features that don't compose:

  • the watch list can raise intraday alerts
  • the frozen-data guard silences intraday alerts when the price hasn't moved materially

The guard was invisible to watch-list tickers, so it silenced nothing for them. Every AI scoring flip alerted. Observed on GOOG (a watch ticker) on 2026-08-04 — five days after the guard shipped:

16:08 AEST  GOOG signal weakened
19:43 AEST  GOOG signal strengthened
22:33 AEST  GOOG signal weakened

Three flips in six and a half hours, two of them on runs where the US market was shut and the daily candles were therefore identical between runs. That is precisely the whipsaw #06395fb was written to suppress.

Why it happened

Watch-list tickers are deliberately excluded from report.items — that exclusion is the feature, and it's what keeps them from polluting allocation percentages. But both price maps in index.ts were built from report.items alone:

const priceMap: Record<string, number> = {};
for (const item of report.items) {   // watch-list tickers are not in here
  priceMap[item.ticker] = item.price;
}

So a watch-list ticker had no price in either the morning baseline or the current map. And the guard requires both to be present:

if (
  triggerType &&
  config.minPriceMovePctToAlert > 0 &&
  morningPrice > 0 &&        // false for a watch ticker
  currentPrice > 0 &&        // false for a watch ticker
  Math.abs(priceDelta) < config.minPriceMovePctToAlert
)

It fails open on a missing price — deliberate, and still the right call for a genuine data gap, since a guard that fails closed on missing data eventually hides something real. But this wasn't a data gap. The prices existed; they simply weren't in the map handed to the guard. So the guard didn't fail open on bad data — it quietly declined to run at all, for exactly the tickers another v1.8 feature had introduced.

The fix

A single shared builder, used by both the daily baseline and the intraday comparison, so the two maps cannot drift apart again:

export function buildPriceMap(report: AllocationReport): Record<string, number> {
  const map: Record<string, number> = {};
  for (const item of report.items) map[item.ticker] = item.price;
  for (const item of report.watchingItems) map[item.ticker] = item.price;
  return map;
}

It lives in src/util.ts rather than analyze.ts for a practical reason: analyze.ts reads config.json at module load, and CI runs without one, so anything defined there can't be unit-tested. util.ts imports AllocationReport as a type only, which erases at runtime.

Tests

104 tests, up from 99. Worth being precise about which ones matter:

  • buildPriceMap coverage — the regression that would actually have caught this. It asserts every watch-list ticker gets a non-zero price, because a zero is what silently disabled the guard.
  • The three-leg GOOG whipsaw through compareWithBaseline — documents intent, but passes both before and after the fix. The defect was never in that function; it was in the caller not supplying data.

That distinction is the lesson: a fail-open guard cannot tell you it isn't running. Both outcomes — "I checked, this is a real move" and "I had no data, so I waved it through" — look identical from outside, because in both an alert arrives. The test that finds this class of bug is a coverage question about the guard's inputs, not a behaviour question about the guard.

Upgrading

Nothing to do. No config, schema, or workflow changes. Watch-list tickers will now be held to the same minPriceMovePctToAlert threshold (default 1.0%) as portfolio tickers, so expect noticeably fewer intraday alerts on them — that's the fix working.

Full changelog: v1.8.0...v1.8.1