Market structure kit

View as MarkdownOpen the editor

The ./sdk/structure module reads price as a sequence of swings and gives you, in every file, the bookkeeping every market-structure indicator rewrites by hand: Swings finds confirmed swing highs and lows and says whether each is a higher or lower one, MarketStructure turns them into breaks of structure and changes of character with a trend, FairValueGaps keeps a ring of three-bar gaps and knows when a close filled each, OrderBlocks keeps the last opposite candle before every break until price trades back into it, Divergence compares price pivots with an oscillator, and the candles namespace answers doji, hammer, engulfing, inside, outside and pin bar as plain predicates. Nothing here repaints: a swing is confirmed right bars after it prints, exactly like the pivots on Series functions, and every other definition is built on that lag, so you do not keep the same state in arrays by hand.

Authors disagree on almost every one of these definitions (strict or loose pivots, wick or close breaks, whether a change of character needs a prior trend, where an order block's edges sit), so each section below spells out the one definition its class implements, and that definition is the contract.

What the module gives you

ExportConstructor and callsIn one line
Swingsnew Swings(left, right); .update(high, low); .lastHigh(), .lastLow(), .barsSinceHigh(), .barsSinceLow(), .newHigh(), .newLow(), .hh(), .hl(), .lh(), .ll(); .reset()Strict pivots of the high and low, confirmed right bars late, each tagged higher or lower than the previous one
MarketStructurenew MarketStructure(left, right); .update(high, low, close); .trend(), .event(), .lastBreakLevel(); .reset()A close beyond the last unbroken swing: BOS (1 / -1) or CHoCH (2 / -2), each swing breakable once
FairValueGapsnew FairValueGaps(max, minSizePct = 0); .update(high, low, close); .count(), .newest(), .top(i), .bottom(i), .direction(i), .age(i), .filled(i); .reset()Three-bar gaps in a ring of the newest max, filled by a close beyond the far edge
OrderBlocksnew OrderBlocks(max, left, right); .update(open, high, low, close); .count(), .newest(), .top(i), .bottom(i), .direction(i), .age(i), .mitigated(i); .reset()The last opposite candle before each break, mitigated when a later bar trades into it
Divergencenew Divergence(left, right); .update(price, osc): i32; .reset()1 regular bullish, 2 hidden bullish, -1 regular bearish, -2 hidden bearish on a pivot's confirmation bar, else 0
candles.doji(o, h, l, c, bodyPct = 10)Body at most bodyPct percent of the range
candles.hammer(o, h, l, c)Long lower wick, short upper wick, close in the top third
candles.shootingStar(o, h, l, c)The hammer mirrored
candles.bullishEngulfing(prevO, prevC, o, c)An up bar whose body covers the previous down bar's body
candles.bearishEngulfing(prevO, prevC, o, c)A down bar whose body covers the previous up bar's body
candles.insideBar(prevH, prevL, h, l)h <= prevH and l >= prevL
candles.outsideBar(prevH, prevL, h, l)h > prevH and l < prevL
candles.pinBar(o, h, l, c, wickRatio = 2)1 for a dominant lower wick, -1 for a dominant upper wick, 0 otherwise

The shared rules: construct in onStart() (every class allocates in its constructor only, so per-bar memory stays flat); call update() once per bar, then read the getters; NaN means "nothing yet" from every price getter and -1 or 0 from the integer ones; reset() restores the freshly built state. Periods and ring sizes below 1 clamp to 1.

Bad data never turns into a signal. A value that is NaN or an infinity is non-finite, and a bar with any non-finite field its class reads (the high, low or close; the open too for order blocks) breaks nothing, opens nothing, fills nothing and mitigates nothing, while ages still count that bar. Each section below says what that means for its class in one line.

Swings

text
        high[k] strictly above its `left` and `right` neighbours
                  |
   .   .   .   .  H  .   .   .   .
   ^   ^   ^   ^     ^   ^   ^   ^
   left = 4              right = 4 -> confirmed here, 4 bars late

new Swings(left, right) runs a PivotHigh over the high and a PivotLow over the low. A swing high is a bar whose high is strictly above the left highs before it and the right highs after it (a tie is not a swing); it is confirmed on the bar right bars later and never repainted. On that confirmation bar newHigh() is true, lastHigh() holds the swing's price, and barsSinceHigh() reads right (it counts from the swing bar, so it is -1 before the first swing and grows by one every bar). hh() and lh() are true on the confirmation bar only, comparing the new swing high with the previous confirmed swing high (strictly higher or strictly lower; the first swing is neither); hl() and ll() do the same for swing lows. A window that holds a non-finite high confirms no swing high, and one that holds a non-finite low confirms no swing low. The sample tags every confirmed swing at its own bar with a label handle, HH / LH above and HL / LL below:

param("left", 5, { min: 1, max: 50, description: "Bars a swing must beat on its left" });
param("right", 5, { min: 1, max: 50, description: "Bars a swing must beat on its right: the confirmation lag" });
input("high", ohlcv.high);
output("swing_high", none, overlay, { description: "The newest confirmed swing high" });
output("swing_low", none, overlay, { description: "The newest confirmed swing low" });
string("tag", { max_bytes: 24 });
handles.label({ size: 11 });

const MAX_TAGS = 16; // per side, the oldest tag is reused
const RED: i32 = rgba(248, 113, 113, 255);
const GREEN: i32 = rgba(74, 222, 128, 255);
function makeLabels(first: i32, count: i32): LabelHandle[] {
  const out: LabelHandle[] = [];
  for (let i = 0; i < count; i += 1) out.push(draw.label(first + i));
  return out;
}
const highTags = makeLabels(0, MAX_TAGS);
const lowTags = makeLabels(MAX_TAGS, MAX_TAGS);

let swings = new Swings(5, 5);
let right: i32 = 5;
let highs: i32 = 0;
let lows: i32 = 0;
let t: f64 = NaN;
let prevT: f64 = NaN;
let width: f64 = NaN;

function onStart(): void {
  right = i32(p_right());
  swings = new Swings(i32(p_left()), right);
}

function onBar(): void {
  prevT = t;
  t = bar.time();
  if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT;
  swings.update(bar.high(), bar.low());
  out_swing_high(swings.lastHigh());
  out_swing_low(swings.lastLow());
  if (!isNaN(width)) {
    // The swing printed `right` bars ago: place the tag on its own bar.
    const x = t - width * f64(right);
    if (swings.newHigh()) {
      const tag = highTags[highs % MAX_TAGS];
      highs += 1;
      sb_clear();
      sb_text(swings.hh() ? "HH " : swings.lh() ? "LH " : "H ");
      sb_f64(swings.lastHigh(), 2);
      tag.set(x, swings.lastHigh()).text(str_tag_sb).color(RED);
    }
    if (swings.newLow()) {
      const tag = lowTags[lows % MAX_TAGS];
      lows += 1;
      sb_clear();
      sb_text(swings.hl() ? "HL " : swings.ll() ? "LL " : "L ");
      sb_f64(swings.lastLow(), 2);
      tag.set(x, swings.lastLow()).text(str_tag_sb).color(GREEN);
    }
  }
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Break of structure and change of character

text
            close above the last unbroken swing high
   swing high ---------------------- .  <- BOS up (1) with the trend, or
                    /\      /       |     CHoCH up (2) when the trend was down
                   /  \    /
                  /    \  /
                        \/  <- swing low: a close below it breaks the other way

new MarketStructure(left, right) keeps its own Swings. The trend starts at 0. Every confirmed swing can be broken once: a close strictly above the last unbroken swing high is an upward break, reported as BOS up (event() is 1) while the trend is 0 or +1 and as CHoCH up (2) while the trend is -1; after it the trend is +1. A close strictly below the last unbroken swing low is the mirror image: BOS down (-1) while the trend is 0 or -1, CHoCH down (-2) while it is +1, then the trend is -1. A newly confirmed swing replaces the previous one of its kind as the level to break. event() is non-zero on the breaking bar only, trend() holds between events, and lastBreakLevel() is the price of the swing the newest break went through. A bar with a non-finite high, low or close breaks nothing: the trend holds and the swing stays breakable by the next finite bar. The sample draws each event as a shape on the breaking bar and the broken level as a line:

param("left", 5, { min: 1, max: 50, description: "Bars a swing must beat on its left" });
param("right", 5, { min: 1, max: 50, description: "Bars a swing must beat on its right" });
input("high", ohlcv.high);
output("bos_up", shape, overlay, { color: "#4ade80", description: "A mark under the bar that closed above the last swing high with the trend" });
output("choch_up", shape, overlay, { color: "#22d3ee", description: "A mark under the bar that turned a downtrend up" });
output("bos_down", shape, overlay, { color: "#f87171", description: "A mark over the bar that closed below the last swing low with the trend" });
output("choch_down", shape, overlay, { color: "#fb923c", description: "A mark over the bar that turned an uptrend down" });
output("level", line, overlay, { color: "#94a3b8", width: 1, description: "The price of the last broken swing" });
output("trend", none, overlay, { description: "1 up, -1 down, 0 before the first break" });

let structure = new MarketStructure(5, 5);

function onStart(): void {
  structure = new MarketStructure(i32(p_left()), i32(p_right()));
}

function onBar(): void {
  const high = bar.high();
  const low = bar.low();
  structure.update(high, low, bar.close());
  // A shape output draws where its value is a price and nothing where it is NaN.
  const event = structure.event();
  out_bos_up(event == 1 ? low : NaN);
  out_choch_up(event == 2 ? low : NaN);
  out_bos_down(event == -1 ? high : NaN);
  out_choch_down(event == -2 ? high : NaN);
  out_level(structure.lastBreakLevel());
  out_trend(f64(structure.trend()));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Fair value gaps

text
   bar 1     bar 2     bar 3
              |
              |          |   <- low of bar 3 above the high of bar 1:
     |        |   ......|      a bullish gap from high[1] (bottom) to
     |   .....|.........|      low[3] (top), open until a close drops
     |        |              below the bottom

new FairValueGaps(max, minSizePct) watches three bars at a time. A bullish gap opens on the bar whose low is strictly above the high two bars back (top = this low, bottom = that high, direction(i) is 1); a bearish gap opens when this high is strictly below the low two bars back (top = that low, bottom = this high, direction(i) is -1). A gap whose height is under minSizePct percent of this bar's close is ignored. Each gap is created with age(i) 0 and ages one per bar; it is filled the first time a later close crosses the far edge (bullish: close below the bottom; bearish: close above the top) and filled(i) stays true. The ring keeps the newest max gaps, filled or not, dropping the oldest: index 0 is the oldest kept and newest() is count() - 1 (-1 while empty). A gap needs all three of its bars finite (high, low and close each), and a bar with a non-finite high, low or close fills nothing; every kept gap still ages on it. The sample draws every kept gap as a box handle from its middle bar, extended to the next bar while it is open and left where it was once filled:

param("min_size_pct", 0.1, { min: 0, max: 10, description: "Smallest gap kept, in percent of the close" });
input("high", ohlcv.high);
output("open_gaps", none, overlay, { description: "Gaps kept in the ring that no close has filled yet" });
handles.box({ opacity: 0.15, borderWidth: 1 });

const MAX_GAPS = 12; // the ring size and the box count, so ring index and box stay aligned
const GREEN: i32 = rgba(74, 222, 128, 255);
const RED: i32 = rgba(248, 113, 113, 255);
function makeBoxes(count: i32): BoxHandle[] {
  const out: BoxHandle[] = [];
  for (let i = 0; i < count; i += 1) out.push(draw.box(i));
  return out;
}
const boxes = makeBoxes(MAX_GAPS);

let gaps = new FairValueGaps(MAX_GAPS, 0.1);
let seen: i32 = 0; // gaps created so far: box (seen - count + i) % MAX_GAPS draws ring index i
let t: f64 = NaN;
let prevT: f64 = NaN;
let width: f64 = NaN;

function onStart(): void {
  gaps = new FairValueGaps(MAX_GAPS, p_min_size_pct());
}

function onBar(): void {
  prevT = t;
  t = bar.time();
  if (!isNaN(prevT) && t > prevT && (isNaN(width) || t - prevT < width)) width = t - prevT;
  gaps.update(bar.high(), bar.low(), bar.close());
  const newest = gaps.newest();
  if (newest >= 0 && gaps.age(newest) == 0) seen += 1;
  let open: f64 = 0.0;
  const count = gaps.count();
  if (!isNaN(width)) {
    for (let i = 0; i < count; i += 1) {
      const box = boxes[(seen - count + i) % MAX_GAPS];
      const filled = gaps.filled(i);
      if (!filled) open += 1.0;
      // The gap opened on the bar `age` bars ago; its middle bar is one earlier.
      const left = t - width * f64(gaps.age(i) + 1);
      const color = gaps.direction(i) > 0 ? GREEN : RED;
      if (filled) {
        box.opacity(0.05); // stops where it was
      } else {
        box.set(left, gaps.top(i), t + width, gaps.bottom(i)).color(color).fill(color).opacity(0.15);
      }
    }
  }
  out_open_gaps(open);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 12 to Aug 18, 2026Real output from OpenMarket's engine

Order blocks

text
   swing high broken here -> close
                             |
                     |   |   |
             |   |   |   |
         |   B   |            B = the last bearish candle before the
             |                    breaking bar (searching back to the
                                  swing high's bar): its high and low are
                                  the bullish order block

new OrderBlocks(max, left, right) runs a MarketStructure of its own. On every BOS or CHoCH it looks back from the bar before the breaking bar to the broken swing's bar (inclusive) for the last bar whose close is against the break direction: a bearish candle (close strictly below open) for an upward break, a bullish candle for a downward break. That bar's high and low are the block's top(i) and bottom(i), direction(i) is 1 for an upward break and -1 for a downward one, and there is at most one block per event (none when no such bar exists in that span). A block is created with age(i) 0 and is mitigated the first time a later bar trades back into it: a bar's low at or below the top of a bullish block, or a bar's high at or above the bottom of a bearish block; mitigated(i) then stays true. The ring keeps the newest max blocks, mitigated or not, oldest first, like the gaps. A bar with a non-finite open, high, low or close is never a block's candle, mitigates nothing and breaks nothing (the break waits for the next finite bar that closes beyond the swing); every kept block still ages on it. Draw them like the gap boxes above: the kit keeps a block's edges, not its bar, so start each box on the breaking bar it was created on (t - width * f64(age(i)); the block's own candle sits between the broken swing and that bar) and stop extending a block once it is mitigated.

Divergence

text
   price     .           .   <- lower low in price
              \         /
               \  .    /
                \/ \  /
                    \/
   RSI       .            <- higher low in the oscillator on the same
              \       .      two pivot bars: regular bullish (+1)
               \     /
                \   /
                 \ /

new Divergence(left, right) finds strict pivots of the price you feed it (the close, usually) and remembers the oscillator's value on each pivot bar. On the bar a pivot is confirmed it compares the two most recent confirmed pivots of that kind with the oscillator on the same two bars: at pivot lows, a lower low in price with a higher low in the oscillator is regular bullish (1) and a higher low in price with a lower low in the oscillator is hidden bullish (2); at pivot highs, a higher high in price with a lower high in the oscillator is regular bearish (-1) and a lower high in price with a higher high in the oscillator is hidden bearish (-2). Every other bar, the first pivot of a kind, and any tie in price or oscillator give 0. A non-finite oscillator value on either of the two pivot bars never diverges (the warm-up of an Rsi is the usual case), and that pivot still becomes the previous one for the next comparison. The sample runs it over the close and a 14-bar Rsi from ./sdk/ta and marks the price bar; the marks carry displacement_bars: -5 so they draw on the pivot bar itself at the default right of 5 (change both together):

param("rsi_period", 14, { min: 2, max: 200, description: "RSI length" });
param("left", 5, { min: 1, max: 50, description: "Bars a price pivot must beat on its left" });
param("right", 5, { min: 1, max: 50, description: "Bars a price pivot must beat on its right" });
output("rsi", line, lower, { color: "#a78bfa", description: "14-bar RSI of the close" });
output("bullish", shape, overlay, { color: "#4ade80", displacement_bars: -5, description: "Regular bullish: lower low in price, higher low in RSI" });
output("hidden_bullish", shape, overlay, { color: "#86efac", displacement_bars: -5, description: "Hidden bullish: higher low in price, lower low in RSI" });
output("bearish", shape, overlay, { color: "#f87171", displacement_bars: -5, description: "Regular bearish: higher high in price, lower high in RSI" });
output("hidden_bearish", shape, overlay, { color: "#fca5a5", displacement_bars: -5, description: "Hidden bearish: lower high in price, higher high in RSI" });
output("kind", none, overlay, { description: "1, 2, -1, -2 on the confirmation bar, else 0" });

let rsi = new Rsi(14);
let divergence = new Divergence(5, 5);

function onStart(): void {
  rsi = new Rsi(i32(p_rsi_period()));
  divergence = new Divergence(i32(p_left()), i32(p_right()));
}

function onBar(): void {
  const close = bar.close();
  const high = bar.high();
  const low = bar.low();
  const value = rsi.update(close);
  // A NaN oscillator on a pivot bar (the RSI warm-up) compares as no divergence.
  const kind = divergence.update(close, value);
  out_rsi(value);
  out_bullish(kind == 1 ? low : NaN);
  out_hidden_bullish(kind == 2 ? low : NaN);
  out_bearish(kind == -1 ? high : NaN);
  out_hidden_bearish(kind == -2 ? high : NaN);
  out_kind(f64(kind));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Candle patterns

text
   doji        hammer      shooting star   engulfing      inside   outside
     |            |             |            | |            |        |
    -+-          -+-            |           -+ |           -+-      -+-
     |            |             |            | |            |       | |
     |            |            -+-           |-+-           |        |
     |            |             |            |             -+-       |

The candles namespace is stateless: each function takes the prices it needs and answers for that bar. Body is abs(close - open), range is high - low, the lower wick is min(open, close) - low and the upper wick is high - max(open, close). doji is true when the body is at most bodyPct percent of the range (a bar with no range counts). hammer needs a lower wick at least twice the body, an upper wick at most the body, and a close in the top third of the range; shootingStar is the mirror image with the close in the bottom third. bullishEngulfing needs this bar closing up, the previous bar closing down, and this body covering the previous body (o <= prevC and c >= prevO); bearishEngulfing is the mirror. insideBar is h <= prevH and l >= prevL; outsideBar is h > prevH and l < prevL. pinBar finds the dominant wick: at least wickRatio times the body and at least as long as the other wick, and answers 1 when that wick is below the body, -1 when it is above, 0 otherwise (two equal qualifying wicks read 1).

Two rules keep degenerate bars out. hammer, shootingStar and pinBar need a positive range (h > l) and a positive dominant wick, so a flat bar (open, high, low and close all equal) is a doji and nothing else: not a hammer, not a shooting star, and pinBar answers 0. And any non-finite argument, a NaN or an infinity in any price, in bodyPct or in wickRatio, makes every predicate answer false (0 from pinBar).

Feed the two-bar patterns the previous bar's values you keep yourself at module level, as the swings sample keeps prevT:

wrun
// In onBar(), with prevOpen / prevClose / prevHigh / prevLow kept from the last bar:
// const engulfed = candles.bullishEngulfing(prevOpen, prevClose, open, close);
// const pin = candles.pinBar(open, high, low, close, 2.5); // 1, -1 or 0
// const quiet = candles.insideBar(prevHigh, prevLow, high, low) && candles.doji(open, high, low, close, 15.0);

From Pine

Only the pivot maps one to one: ta.pivothigh(left, right) and ta.pivotlow(left, right) are PivotHigh and PivotLow on the TA library page, and Swings is the pair of them with the bookkeeping. Structure breaks, gaps, order blocks, divergence and the candle predicates have no Pine builtin; they are the definitions above.