diff --git a/packages/shared/src/screening/strategies.test.ts b/packages/shared/src/screening/strategies.test.ts index 6f0c0c1f..6903c676 100644 --- a/packages/shared/src/screening/strategies.test.ts +++ b/packages/shared/src/screening/strategies.test.ts @@ -197,6 +197,17 @@ describe('market-movers rules', () => { expect(rule.compute(makeContext({ kline: bars(3), valuation: { symbol: 'AAPL.US' } }))).toBeNull() }) + it('high-volume skips the kline ratio when the latest bar volume is unknown', () => { + const rule = getScreeningStrategy('high-volume')! + const klines = bars(30, 100, 1, 100_000) + // Yesterday spiked, but today's (latest bar) volume is missing/NaN — + // the ratio must stay unknown instead of labelling yesterday's spike as today. + klines[klines.length - 2] = { ...klines[klines.length - 2], volume: 400_000 } + klines[klines.length - 1] = { ...klines[klines.length - 1], volume: Number.NaN } + const result = rule.compute(makeContext({ kline: klines, valuation: { symbol: 'AAPL.US' } })) + expect(result).toBeNull() + }) + it('unusual-movement flags amplitude far above the 20d average', () => { const rule = getScreeningStrategy('unusual-movement')! const klines = bars(30, 100, 0, 1_000_000) // flat: amplitude ~2% per bar diff --git a/packages/shared/src/screening/strategies.ts b/packages/shared/src/screening/strategies.ts index 189030d5..553fa846 100644 --- a/packages/shared/src/screening/strategies.ts +++ b/packages/shared/src/screening/strategies.ts @@ -296,10 +296,15 @@ const highVolume: ScreeningStrategyDef = { ? [...ctx.data.kline].sort((a, b) => a.timestamp - b.timestamp).map((bar) => toFiniteNumber(bar.volume)) : [] const klineRatio = (() => { - const finite = barVolumes.filter((value): value is number => value !== undefined) - if (finite.length <= BREAKOUT_WINDOW) return undefined - const today = finite[finite.length - 1] - const baseline = finite.slice(-BREAKOUT_WINDOW - 1, -1) + // "Today" must be the actual latest bar by timestamp. Filtering out + // unknown volumes first would shift the index and mislabel yesterday's + // volume as today's, so the latest bar's volume is required to be known. + const today = barVolumes[barVolumes.length - 1] + if (today === undefined) return undefined + const baseline = barVolumes + .slice(-BREAKOUT_WINDOW - 1, -1) + .filter((value): value is number => value !== undefined) + if (baseline.length < BREAKOUT_WINDOW) return undefined const avg = baseline.reduce((acc, value) => acc + value, 0) / baseline.length if (avg === 0) return undefined return today / avg