Market structure kit
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
| Export | Constructor and calls | In one line |
|---|---|---|
Swings | new Swings(left, right); .update(high, low); .lastHigh(), .lastLow(), ., .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 |
MarketStructure | new Market; .update(high, low, close); .trend(), .event(), .; .reset() | A close beyond the last unbroken swing: BOS (1 / -1) or CHoCH (2 / -2), each swing breakable once |
FairValueGaps | new Fair; .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 |
OrderBlocks | new Order; .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 |
Divergence | new Divergence(; .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. | Long lower wick, short upper wick, close in the top third | |
candles. | The hammer mirrored | |
candles. | An up bar whose body covers the previous down bar's body | |
candles. | A down bar whose body covers the previous up bar's body | |
candles. | h <= prevH and l >= prevL | |
candles. | h > prevH and l < prevL | |
candles. | 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
high[k] strictly above its `left` and `right` neighbours
|
. . . . H . . . .
^ ^ ^ ^ ^ ^ ^ ^
left = 4 right = 4 -> confirmed here, 4 bars latenew 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);
}
}
}Break of structure and change of character
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 waynew 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()));
}Fair value gaps
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 bottomnew 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);
}Order blocks
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 blocknew 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
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));
}Candle patterns
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:
// 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.