1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
// Stop Clusters: estimates where buyers' stop-losses are likely to sit and
// shows what happened when price came down to take them out.
//
// Stop orders are not public, so this is a model, not data. It assumes a long
// trader puts a stop a little below a recent swing low.
//
// How it works:
// 1. Find swing lows and place one estimated stop just below each.
// 2. Walk the bars oldest to newest. A bar whose low reaches a stop
// "sweeps" it: the stop is removed and the sweep is recorded.
// 3. Follow each sweep for up to five bars: did buyers take the level back
// (defended) or not (failed)?
// 4. Group the stops still waiting into clusters and draw the biggest ones
// on the latest bar.
// Only completed bars are used, so markers never change on a bar in progress.
requires settled_only
// Inputs:
// lookback how many recent bars to look for swing lows in
// stop_buffer_atr how far below a swing low the stop sits, in ATRs
// maximum_clusters how many waiting clusters to draw
input lookback: integer = 120 min 30 max 250
input stop_buffer_atr: number = 0.10 min 0.05 max 1
input maximum_clusters: integer = 5 min 1 max 8
// Records (`type`) used below. A swing low's estimated stop:
type swing_low_stop {
key: bar_key
stop: number
}
// What the walk in step 2 needs from each bar. `option<...>` means the value
// may be missing: most bars do not confirm a swing low.
type bar_info {
key: bar_key
high: number
low: number
close: number
atr: number
valid: boolean
new_stop: option<swing_low_stop>
}
// One estimated stop that has not been swept yet. Every swing low counts the
// same (weight 1), whatever the volume.
type estimated_stop {
swing_low_key: bar_key
stop: number
weight: number
}
// A cluster marker drawn on the latest bar. Its size (magnitude) is the
// cluster's share of all waiting stops, counting at least five.
type cluster_marker {
id: string
at_key: bar_key
value: number
magnitude: number
}
// A sweep and how it is resolving. status is "pending", "defended" or
// "failed"; role is the colour it draws in.
type sweep_event {
id: string
at_key: bar_key
value: number
magnitude: number
role: visual_role
sweep_atr: number
best_close: number
consecutive_reclaims: integer
age: integer
status: string
}
// A possible cluster before the biggest ones are picked.
type cluster_candidate {
lower: number
upper: number
weight: number
marker: cluster_marker
}
// Helper functions. A bar with impossible prices (or no ATR yet) makes the
// whole picture untrustworthy, so it is checked first.
function valid_bar(b: bar_info): boolean =
b.valid and b.high >= b.low and b.close >= b.low and b.close <= b.high
and b.atr > 0
// `xs[row]?` reads a list item that may not exist; `?? 0.0` uses 0 instead.
function value_or_zero(xs: list<number, 64>, row: integer): number =
xs[row]? ?? 0.0
// A row is a peak when its smoothed count is above the row below it and at
// least as high as the row above it (so a flat top still counts once).
function is_peak(xs: list<number, 64>, row: integer): boolean = {
let value = value_or_zero(xs, row)
let left = value_or_zero(xs, row - 1)
let right = value_or_zero(xs, row + 1)
value > 0 and value > left and value >= right
}
// Step 3: move a pending sweep on by one bar. Defended needs two closes in a
// row back at or above the level, and a close at least half an ATR above
// it. Failed is a close half an ATR or more below the level, or five bars
// without a defense. ATR is the value on the sweep bar. Once a sweep is
// defended or failed it never changes again.
function advance_sweep_event(event: sweep_event,
b: bar_info): sweep_event =
if event.status != "pending" then event else {
let age = event.age + 1
let best_close = max(event.best_close, b.close)
let consecutive = if b.close >= event.value
then event.consecutive_reclaims + 1 else 0
let defended = consecutive >= 2
and best_close >= event.value + 0.5 * event.sweep_atr
let failed = b.close <= event.value - 0.5 * event.sweep_atr
or age >= 5
event with {
role = if defended then positive else if failed then neutral else warning,
best_close = best_close,
consecutive_reclaims = consecutive,
age = age,
status = if defended then "defended" else if failed then "failed" else "pending"
}
}
// The stops whose price falls inside a price range.
function cluster_members(xs: list<estimated_stop, 250>, lower: number,
upper: number): list<estimated_stop, 250> =
filter(xs, x -> x.stop >= lower and x.stop <= upper)
// Turn a peak row into a cluster: take the stops within two rows of the
// peak, and place the marker at their average price. The marker id comes
// from the oldest stop, so it stays the same while the cluster grows.
function measure_cluster(xs: list<estimated_stop, 250>,
minimum_stop: number, row_width: number,
size_total: number, current_key: bar_key,
row: integer): cluster_candidate = {
let lower_row = max(0, row - 2)
let upper_row = min(63, row + 2)
let lower = minimum_stop + lower_row * row_width
let upper = minimum_stop + (upper_row + 1) * row_width
let members = cluster_members(xs, lower, upper)
let weight = members |> map(x -> x.weight) |> sum()
let weighted_price = sum(map(members, x -> x.stop * x.weight)) / weight
let oldest = get_or(members[0]?, estimated_stop {
swing_low_key = current_key,
stop = weighted_price,
weight = weight
})
cluster_candidate {
lower = lower,
upper = upper,
weight = weight,
marker = cluster_marker {
id = stable_key("stop_cluster_active", oldest.swing_low_key),
at_key = current_key,
value = weighted_price,
magnitude = weight / size_total
}
}
}
// Two clusters overlap when their price ranges touch.
function overlaps(a: cluster_candidate, b: cluster_candidate): boolean =
a.lower <= b.upper and a.upper >= b.lower
// Step 1: series computed on every bar.
// `ta.pivot_low(l, 3, 3, latest)` reports a swing low three bars after it,
// once the three bars after it have higher lows and none of the three
// before it went lower.
let volatility = atr(14)
let valid_price = ta.valid_ohlcv(o, h, l, c, v)
let confirmed_low = ta.pivot_low(l, 3, 3, latest)
// `history(...)` collects one record per bar for the last `lookback` bars
// (and needs at least 30). When a swing low is confirmed, its stop goes
// stop_buffer_atr x ATR below the low, using ATR from the swing-low bar
// (`ref(x, 3)` is x three bars ago).
let recent_bars = history(bar_info {
key = bar.key,
high = h,
low = l,
close = c,
atr = volatility,
valid = valid_price,
new_stop = if_some(confirmed_low, low ->
if valid_price and ref(valid_price, 3) then some(swing_low_stop {
key = low.key,
stop = low.value - stop_buffer_atr * ref(volatility, 3)
}) else none<swing_low_stop>, none<swing_low_stop>)
}, max = lookback, min = 30)
// If any bar in the window is bad, use no bars at all: draw nothing rather
// than a picture built on bad data.
let bars_to_walk = get_or(
if any(recent_bars, b -> not valid_bar(b))
then none<list<bar_info, 250>>
else some(recent_bars),
empty<bar_info, 250>())
// Step 2: `replay` walks the bars oldest to newest, keeping `state`
// variables that each bar can update.
let walk = replay bars_to_walk {
state stops: list<estimated_stop, 250> = []
state sweeps: list<sweep_event, 8> = []
on bar b {
// First move earlier sweeps on by this bar.
sweeps = map(sweeps, event -> advance_sweep_event(event, b))
// Then check whether this bar's low took out any waiting stops. If so,
// record one sweep at their average price, sized by the share of
// waiting stops it took (counting at least five). A close half an ATR
// below fails it at once.
// `|>` passes the value on the left into the next function.
let waiting_weight = stops |> map(x -> x.weight) |> sum()
let swept = filter(stops, x -> b.low <= x.stop)
let swept_weight = sum(map(swept, x -> x.weight))
if len(swept) > 0 {
let event_price = sum(map(swept, x -> x.stop * x.weight)) / swept_weight
let immediately_failed = b.close <= event_price - 0.5 * b.atr
let event = sweep_event {
id = stable_key("stop_cluster_sweep", b.key),
at_key = b.key,
value = event_price,
magnitude = swept_weight / max(waiting_weight, 5.0),
role = if immediately_failed then neutral else warning,
sweep_atr = b.atr,
best_close = b.close,
consecutive_reclaims = if b.close >= event_price then 1 else 0,
age = 0,
status = if immediately_failed then "failed" else "pending"
}
sweeps = append_capped(sweeps, event)
}
// Swept stops are gone. If this bar confirms a swing low, add its stop.
stops = filter(stops, x -> b.low > x.stop)
if let found = b.new_stop {
stops = get_or(append(stops, estimated_stop {
swing_low_key = found.key,
stop = found.stop,
weight = 1.0
}), stops)
}
}
}
// Step 4: find clusters among the stops still waiting. Spread their prices
// over 64 rows (a histogram), smooth it so nearby stops merge, then treat
// each peak as a cluster. Keep the biggest ones that do not overlap, up to
// maximum_clusters (`collect` builds the list, `emit` adds to it).
let active_markers = if len(walk.stops) == 0
then empty<cluster_marker, 8>() else {
let current_atr = volatility
let minimum_stop = get_or(min(map(walk.stops, x -> x.stop)), c - current_atr) - 0.25 * current_atr
let profile_upper = get_or(max(map(walk.stops, x -> x.stop)), c - current_atr) + 0.25 * current_atr
let row_width = (profile_upper - minimum_stop) / 64
let raw = histogram(walk.stops,
value = x -> x.stop, weight = x -> x.weight,
lower = minimum_stop, upper = profile_upper, bins = 64)
let density = convolve(raw,
[1/9, 2/9, 3/9, 2/9, 1/9], boundary = zero)
let total_weight = sum(map(walk.stops, x -> x.weight))
let size_total = max(total_weight, 5.0)
let peaks = filter(range(0, 64), row -> is_peak(density, row))
let candidates = map(peaks,
row -> measure_cluster(walk.stops, minimum_stop,
row_width, size_total, bar.key, row))
let ordered = sort_by(candidates,
desc(candidate -> candidate.weight),
desc(candidate -> candidate.marker.value))
let selected = collect<cluster_candidate, 8> {
for candidate in ordered {
if emitted == maximum_clusters { break }
if not any(collected, existing -> overlaps(existing, candidate)) {
emit candidate
}
}
}
map(selected, candidate -> candidate.marker)
}
// Outputs. `snapshot` outputs are drawn objects for the current picture,
// not one value per bar.
output snapshot active_markers: list<cluster_marker, 8> = active_markers
output snapshot sweep_markers: list<sweep_event, 8> = walk.sweeps
// How it looks: waiting clusters in amber on the latest bar; each sweep on
// the bar that made it, coloured by how it resolved.
view {
pane price
marker active_clusters on each item of active_markers {
id .id
at bar .at_key value .value
size .magnitude
role warning
}
marker sweep_events on each item of sweep_markers {
id .id
at bar .at_key value .value
size .magnitude
role .role
}
}