RSI/momentum divergence detection that provably does not repaint. Most public
divergence indicators draw a divergence at the pivot bar — bars before the pivot
was even knowable — and silently redraw it as new data arrives. This library emits
a divergence only at the bar where it first became knowable, stamps that bar on the
signal (confirmedAtIndex), and ships a test suite that proves reported
divergences never mutate or disappear as bars append. Built on
market-structure-engine for pivot detection.
Part of the Visor chart intelligence stack.
- Wilder RSI (default, period 14), implemented from scratch: close-to-close
changes, first averages = simple mean of the first
periodchanges, then Wilder smoothingavg = (avg·(p−1) + x)/p. Verified against the classic published worked example to 4 decimal places. Conventions:avgLoss = 0→ 100, dead-flat → 50. First value at bar indexperiod. - ROC / momentum:
100·(close − close[p ago])/close[p ago]. - Pluggable: anything implementing
{ name, range, update(candle): number|null, reset() }works — warm-up must be a contiguous null prefix; declarerange(e.g.[0,100]) ornullfor unbounded oscillators.
Price pivots are k-bar fractal swings from market-structure-engine (default
fractalK = 2): a swing high needs k strictly-lower highs on each side, so it
confirms exactly k bars after the pivot. Oscillator pivots use the same
k-bar rule applied to the oscillator series. A price pivot and an oscillator
pivot of the same side pair when their bar indices differ by at most
maxBarOffset (default 3); matching is greedy nearest-first and each pivot pairs
at most once.
Each new pair is compared with the previous pair on its side:
| type | price | oscillator | side |
|---|---|---|---|
| regular bullish | LL | HL | lows |
| regular bearish | HH | LH | highs |
| hidden bullish | HL | LL | lows |
| hidden bearish | LH | HH | highs |
Exact ties in either series → no divergence.
ps = clamp(±1, ((p2 − p1)/p1) / 0.02) # price leg, saturates at 2%
os = clamp(±1, (o2 − o1) / (0.15·span)) # osc leg, saturates at 15% of span
slopeDifferential = |ps − os| / 2 # legs oppose by definition → (0,1]
pivotExtremity = min(1, |o2 − mid| / (0.3·span))
strength = clamp(0, 1, 0.5·slopeDifferential + 0.5·pivotExtremity)
span/mid come from the oscillator's declared range (RSI: 100/50), or from
the running observed min/max at confirmation time for unbounded oscillators —
still computed from past data only. The constants are pragmatic saturation
points, not fitted parameters; the formula is deliberately simple, monotonic in
divergence magnitude, and rewards divergences at oscillator extremes.
A divergence needs its later price pivot and later oscillator pivot confirmed:
- every pivot confirms
fractalKbars after its bar; - the paired oscillator pivot may sit up to
maxBarOffsetbars past the price pivot; - worst-case lag:
maxBarOffset + fractalKbars after the later price pivot (default: 5 bars). Typical case (aligned pivots):fractalKbars.
confirmedAtIndex on every Divergence records the exact bar the signal became
knowable; the adapter deliberately places markers there, not at the pivot. The
test suite asserts the prefix property on real BTC data and seeded random walks:
process(candles[0..n]) reports exactly the divergences of the full run with
confirmedAtIndex < n.
npm install divergence-detectorNode 20+. Runtime dependency: market-structure-engine only.
import { DivergenceDetector, rsi } from 'divergence-detector';
const detector = new DivergenceDetector({ oscillator: rsi(14), fractalK: 2 });
// batch over history…
const { divergences } = detector.process(candles);
// …then stream live closed bars
const step = detector.update(nextClosedCandle);
for (const d of step.divergences) {
console.log(d.type, 'confirmed at bar', d.confirmedAtIndex, 'strength', d.strength);
}Batch and incremental modes produce identical output for the same candles (tested).
| option | default | meaning |
|---|---|---|
oscillator |
rsi(14) |
any Oscillator implementation |
fractalK |
2 |
pivot confirmation half-width (price and oscillator) |
maxBarOffset |
3 |
max index distance between paired pivots |
process(candles): { divergences }— reset + run over the whole array.update(candle): { divergences, oscValue }— feed one closed candle; returns the divergences that became knowable on this bar plus the oscillator value.- getters:
divergences,oscillatorName,candlesProcessed;reset().
{
type: 'regular-bullish' | 'regular-bearish' | 'hidden-bullish' | 'hidden-bearish',
side: 'low' | 'high',
priceSegment: { from, to }, // { index, time, price, confirmedAtIndex }
oscSegment: { from, to }, // { index, time, value, confirmedAtIndex }
confirmedAtIndex, confirmedAtTime,
strength, // 0–1
oscillator, // e.g. 'rsi(14)'
}rsi(period = 14), roc(period = 10), or bring your own Oscillator.
toDivergenceSegments(divergences)— twoPaneSegments per divergence (price pane + oscillator pane) withstyleKeyandstrengthfor theming.toConfirmationMarkers(divergences)— one marker per divergence at the confirmation bar (not the pivot bar), arrow up/down by direction.
npm install && npm --prefix ../market-structure-engine run build
npm run demo # RSI(14) divergences on 1000 real BTCUSDT 1h candlesnpm testWilder RSI exactness against the published reference sequence, exact-segment
synthetic divergences of all four types (via a scripted oscillator), pairing and
maxBarOffset behaviour, the non-repaint prefix property on real and synthetic
data with both bounded and unbounded oscillators, batch ≡ incremental
equivalence, and a real-fixture snapshot.
Chart overlays coming soon.
Educational and analytical tooling. Not investment advice, not a trading signal service, and not a financial promotion.