Non-repainting swing-structure detection for OHLCV candles: Williams-style fractal swings labelled HH / HL / LH / LL, a bullish/bearish trend state machine, and confirmed BOS (Break of Structure) / CHoCH (Change of Character) events. Zero runtime dependencies, batch and streaming APIs with provably identical output. Part of the Visor chart intelligence stack — its overlay adapter emits plain data shapes ready for lightweight-charts style rendering.
A swing high at bar i is a bar whose high is strictly greater than the highs
of the k bars on each side (default k = 2); mirror rule for swing lows. Strict
inequality means exact-tie plateaus do not form swings — detection stays
deterministic and side-agnostic.
Confirmation lag — the honest part. The window around bar i is only complete when bar i + k closes, so a swing is emitted exactly k bars after its pivot and carries both indices:
SwingPoint { index, time, price, kind, confirmedAtIndex, label }
^ always index + k
Once emitted, a swing never changes. The test suite proves the prefix property:
running the engine on the first n candles produces exactly the outputs of the full
run whose confirmation bar is < n — nothing mutates or disappears when future bars
are appended.
Each confirmed swing is compared with the previous confirmed swing of the same kind:
| kind | condition | label |
|---|---|---|
| high | price > previous high | HH |
| high | price ≤ previous high | LH |
| low | price < previous low | LL |
| low | price ≥ previous low | HL |
The first swing of each kind is unlabelled (label: null). Ties resolve to the
"weaker" label (LH / HL).
The engine tracks the most recent unbroken confirmed swing high and swing low as reference levels, plus a trend state ∈ {bullish, bearish, undetermined}. On every bar close (confirmed-close rule — intrabar wicks do not trigger by default):
- close above the reference swing high →
- state was
bearish→ CHoCH (bullish), state flips to bullish - otherwise → BOS (bullish), state becomes/stays bullish
- state was
- close below the reference swing low → mirror rules.
A reference level can be broken at most once; the next event in that direction needs
a newer confirmed swing. The first break out of undetermined is reported as a BOS
that establishes the trend. With wickMode: true, the bar's high/low replaces the
close as the trigger price (documented in the event's price field either way).
Event lag: zero bars past the triggering close — but note the level itself
appears only k bars after its pivot, so a break that happens inside a swing's own
confirmation window cannot be detected against that swing.
resample(candles, intervalMs) / Resampler aggregate a lower timeframe into a
higher one (e.g. 15m → 1h) with buckets aligned to floor(time / interval). A
bucket is emitted only when the next bucket starts, so emitted candles are final —
the same non-repainting discipline. The test suite cross-checks resampled 15m
BTCUSDT candles against Binance's own 1h klines, exactly.
npm install market-structure-engineNode 20+. Zero runtime dependencies. Ships ESM + CJS + type declarations.
import { StructureEngine } from 'market-structure-engine';
const engine = new StructureEngine({ fractalK: 2 });
// batch over history…
const { swings, events, state } = engine.process(candles);
// …then stream live closed bars; update returns only what the bar added
const step = engine.update(nextClosedCandle);
for (const event of step.events) {
console.log(event.type, event.direction, 'broke', event.brokenSwing.price);
}Batch and incremental modes produce identical output for the same candles (tested).
| option | type | default | meaning |
|---|---|---|---|
fractalK |
number |
2 |
fractal half-width; confirmation lag in bars |
wickMode |
boolean |
false |
trigger breaks on wick extremes instead of close |
process(candles): StructureResult— reset + run over the whole array.update(candle): UpdateResult— feed one closed candle; returns the swings confirmed and the events triggered by that bar, plus the current state.- getters:
swings,events,state,candlesProcessed;reset().
The standalone incremental fractal detector (update(candle): SwingPoint[]), if you
only need pivots.
Batch and incremental timeframe aggregation. The trailing partial bucket is dropped
by default; Resampler.update returns a bucket only once it is complete, and
flush() hands back the in-progress partial.
Pure data mappers for chart overlays — no dependency on any charting package:
toSwingMarkers(swings)— one marker per swing at the pivot bar, labelled HH/HL/LH/LL, with astyleKeyfor theming.toEventMarkers(events)— BOS/CHoCH arrows at the triggering bar.toBrokenLevelSegments(events)— a horizontal segment per broken level, from pivot bar to break bar at the swing price.
npm install
npm run demo # prints swings/events tables from 1000 real BTCUSDT 1h candlesnpm testCovers synthetic patterns (uptrend BOS chain, CHoCH reversal, range), wick-vs-close trigger rules, the non-repaint prefix property on real and synthetic data, batch ≡ incremental equivalence, resampling cross-checked against real Binance 1h klines, 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.