@tradejs/strategy-head-and-shoulders 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +96 -0
- package/README.md +34 -0
- package/dist/index.cjs +1074 -0
- package/dist/index.d.cts +103 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +1050 -0
- package/package.json +65 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
HeadAndShouldersStrategyDefinition: () => HeadAndShouldersStrategyDefinition,
|
|
24
|
+
default: () => index_default,
|
|
25
|
+
getBuiltInStrategyDefaultConfig: () => getBuiltInStrategyDefaultConfig,
|
|
26
|
+
headAndShouldersAiAdapter: () => headAndShouldersAiAdapter,
|
|
27
|
+
headAndShouldersDefaultConfig: () => config,
|
|
28
|
+
headAndShouldersManifest: () => headAndShouldersManifest,
|
|
29
|
+
strategyEntries: () => strategyEntries
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
var import_config4 = require("@tradejs/core/config");
|
|
33
|
+
|
|
34
|
+
// src/HeadAndShoulders/config.ts
|
|
35
|
+
var import_constants = require("@tradejs/core/constants");
|
|
36
|
+
var config = {
|
|
37
|
+
ENV: "BACKTEST",
|
|
38
|
+
INTERVAL: "15",
|
|
39
|
+
MAKE_ORDERS: true,
|
|
40
|
+
CLOSE_OPPOSITE_POSITIONS: false,
|
|
41
|
+
BACKTEST_PRICE_MODE: "open",
|
|
42
|
+
AI_ENABLED: false,
|
|
43
|
+
AI_MODE: "llm",
|
|
44
|
+
ML_ENABLED: false,
|
|
45
|
+
ML_THRESHOLD: 0.1,
|
|
46
|
+
MIN_AI_QUALITY: 4,
|
|
47
|
+
FEE_PERCENT: import_constants.FEE_PERCENT,
|
|
48
|
+
MAX_LOSS_VALUE: 10,
|
|
49
|
+
MA_FAST: 14,
|
|
50
|
+
MA_MEDIUM: 49,
|
|
51
|
+
MA_SLOW: 50,
|
|
52
|
+
OBV_SMA: 10,
|
|
53
|
+
ATR: 14,
|
|
54
|
+
ATR_PCT_SHORT: 7,
|
|
55
|
+
ATR_PCT_LONG: 30,
|
|
56
|
+
BB: 20,
|
|
57
|
+
BB_STD: 2,
|
|
58
|
+
MACD_FAST: 12,
|
|
59
|
+
MACD_SLOW: 26,
|
|
60
|
+
MACD_SIGNAL: 9,
|
|
61
|
+
HEADSHOULDERS_PIVOT_LOOKBACK: 3,
|
|
62
|
+
HEADSHOULDERS_SHOULDER_TOLERANCE_PCT: 15,
|
|
63
|
+
HEADSHOULDERS_MIN_HEAD_PROMINENCE_RATIO: 0.2,
|
|
64
|
+
HEADSHOULDERS_TARGET_HEIGHT_PCT: 100,
|
|
65
|
+
HEADSHOULDERS_TARGET_HEIGHT_PCT_LONG: 120,
|
|
66
|
+
HEADSHOULDERS_TARGET_HEIGHT_PCT_SHORT: 100,
|
|
67
|
+
HEADSHOULDERS_STOP_BUFFER_HEIGHT_PCT: 5,
|
|
68
|
+
HEADSHOULDERS_MIN_HEAD_HEIGHT_PCT: 0.8,
|
|
69
|
+
HEADSHOULDERS_MIN_HEAD_HEIGHT_ATR: 2.5,
|
|
70
|
+
HEADSHOULDERS_ATR_PERIOD: 14,
|
|
71
|
+
HEADSHOULDERS_MIN_PATTERN_BARS: 8,
|
|
72
|
+
HEADSHOULDERS_MAX_PATTERN_BARS: 180,
|
|
73
|
+
HEADSHOULDERS_MIN_PATTERN_SYMMETRY_RATIO: 0.45,
|
|
74
|
+
HEADSHOULDERS_MAX_NECKLINE_SLOPE_RATIO: 0.2,
|
|
75
|
+
HEADSHOULDERS_MAX_PATTERN_AGE_BARS: 220,
|
|
76
|
+
HEADSHOULDERS_PRIOR_TREND_LOOKBACK: 80,
|
|
77
|
+
HEADSHOULDERS_MAX_PRIOR_MOVE_ATR: 3,
|
|
78
|
+
HEADSHOULDERS_MIN_BREAKOUT_DISTANCE_ATR: 0.2,
|
|
79
|
+
HEADSHOULDERS_MAX_BREAKOUT_DISTANCE_HEIGHT_RATIO: 0.35,
|
|
80
|
+
HEADSHOULDERS_MAX_BREAKOUT_DISTANCE_PCT: 0.8,
|
|
81
|
+
HEADSHOULDERS_MAX_BREAKOUT_DELAY_BARS: 8,
|
|
82
|
+
HEADSHOULDERS_REQUIRE_BREAKOUT_CROSS: false,
|
|
83
|
+
HEADSHOULDERS_ENTRY_MODE: "close_acceptance",
|
|
84
|
+
HEADSHOULDERS_CONFIRMATION_MAX_BARS: 2,
|
|
85
|
+
HEADSHOULDERS_MIN_CONFIRMATION_BODY_ATR: 0,
|
|
86
|
+
HEADSHOULDERS_MAX_CONFIRMATION_CLOSE_LOCATION: 1,
|
|
87
|
+
HEADSHOULDERS_CONFIRMATION_VOLUME_PERIOD: 20,
|
|
88
|
+
HEADSHOULDERS_MIN_CONFIRMATION_VOLUME_REL: 0.8,
|
|
89
|
+
HEADSHOULDERS_RETEST_MAX_BARS: 4,
|
|
90
|
+
HEADSHOULDERS_RETEST_TOLERANCE_ATR: 0.25,
|
|
91
|
+
HEADSHOULDERS_MIN_SIGNAL_BODY_STRENGTH: 0,
|
|
92
|
+
HEADSHOULDERS_MIN_SIGNAL_BODY_STRENGTH_LONG: 0.5,
|
|
93
|
+
HEADSHOULDERS_MIN_SIGNAL_BODY_STRENGTH_SHORT: 0,
|
|
94
|
+
HEADSHOULDERS_MIN_ENTRY_HEAD_HEIGHT_ATR: 0,
|
|
95
|
+
HEADSHOULDERS_MIN_ENTRY_HEAD_HEIGHT_ATR_LONG: 4,
|
|
96
|
+
HEADSHOULDERS_MIN_ENTRY_HEAD_HEIGHT_ATR_SHORT: 0,
|
|
97
|
+
HEADSHOULDERS_EXIT_ON_OPPOSITE_PATTERN: false,
|
|
98
|
+
LONG: {
|
|
99
|
+
enable: false,
|
|
100
|
+
direction: "LONG",
|
|
101
|
+
minRiskRatio: 0.7
|
|
102
|
+
},
|
|
103
|
+
SHORT: {
|
|
104
|
+
enable: true,
|
|
105
|
+
direction: "SHORT",
|
|
106
|
+
minRiskRatio: 0.7
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/HeadAndShoulders/core.ts
|
|
111
|
+
var import_math = require("@tradejs/core/math");
|
|
112
|
+
|
|
113
|
+
// src/HeadAndShoulders/engine.ts
|
|
114
|
+
var import_config = require("@tradejs/strategy-kit/config");
|
|
115
|
+
var asNumber = (value) => {
|
|
116
|
+
const numeric = Number(value);
|
|
117
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
118
|
+
};
|
|
119
|
+
var clampNumber = ({
|
|
120
|
+
value,
|
|
121
|
+
fallback,
|
|
122
|
+
min,
|
|
123
|
+
max
|
|
124
|
+
}) => {
|
|
125
|
+
const numeric = Number(value);
|
|
126
|
+
const resolved = Number.isFinite(numeric) ? numeric : fallback;
|
|
127
|
+
return Math.min(max ?? Infinity, Math.max(min, resolved));
|
|
128
|
+
};
|
|
129
|
+
var getConfigNumbers = (config2) => ({
|
|
130
|
+
pivotLookback: Math.max(
|
|
131
|
+
1,
|
|
132
|
+
Math.floor(Number(config2.HEADSHOULDERS_PIVOT_LOOKBACK ?? 3))
|
|
133
|
+
),
|
|
134
|
+
shoulderTolerancePct: clampNumber({
|
|
135
|
+
value: config2.HEADSHOULDERS_SHOULDER_TOLERANCE_PCT,
|
|
136
|
+
fallback: 20,
|
|
137
|
+
min: 0
|
|
138
|
+
}),
|
|
139
|
+
minHeadProminenceRatio: clampNumber({
|
|
140
|
+
value: config2.HEADSHOULDERS_MIN_HEAD_PROMINENCE_RATIO,
|
|
141
|
+
fallback: 0.2,
|
|
142
|
+
min: 0
|
|
143
|
+
}),
|
|
144
|
+
targetHeightPctLong: clampNumber({
|
|
145
|
+
value: (0, import_config.resolveDirectionalConfigNumber)({
|
|
146
|
+
config: config2,
|
|
147
|
+
key: "HEADSHOULDERS_TARGET_HEIGHT_PCT",
|
|
148
|
+
direction: "LONG",
|
|
149
|
+
fallback: 100
|
|
150
|
+
}),
|
|
151
|
+
fallback: 100,
|
|
152
|
+
min: 0
|
|
153
|
+
}),
|
|
154
|
+
targetHeightPctShort: clampNumber({
|
|
155
|
+
value: (0, import_config.resolveDirectionalConfigNumber)({
|
|
156
|
+
config: config2,
|
|
157
|
+
key: "HEADSHOULDERS_TARGET_HEIGHT_PCT",
|
|
158
|
+
direction: "SHORT",
|
|
159
|
+
fallback: 100
|
|
160
|
+
}),
|
|
161
|
+
fallback: 100,
|
|
162
|
+
min: 0
|
|
163
|
+
}),
|
|
164
|
+
stopBufferHeightPct: clampNumber({
|
|
165
|
+
value: config2.HEADSHOULDERS_STOP_BUFFER_HEIGHT_PCT,
|
|
166
|
+
fallback: 5,
|
|
167
|
+
min: 0
|
|
168
|
+
}),
|
|
169
|
+
minHeadHeightPct: clampNumber({
|
|
170
|
+
value: config2.HEADSHOULDERS_MIN_HEAD_HEIGHT_PCT,
|
|
171
|
+
fallback: 0.8,
|
|
172
|
+
min: 0
|
|
173
|
+
}),
|
|
174
|
+
minHeadHeightAtr: clampNumber({
|
|
175
|
+
value: config2.HEADSHOULDERS_MIN_HEAD_HEIGHT_ATR,
|
|
176
|
+
fallback: 2,
|
|
177
|
+
min: 0
|
|
178
|
+
}),
|
|
179
|
+
atrPeriod: Math.max(
|
|
180
|
+
2,
|
|
181
|
+
Math.floor(Number(config2.HEADSHOULDERS_ATR_PERIOD ?? 14))
|
|
182
|
+
),
|
|
183
|
+
minPatternBars: Math.max(
|
|
184
|
+
4,
|
|
185
|
+
Math.floor(Number(config2.HEADSHOULDERS_MIN_PATTERN_BARS ?? 8))
|
|
186
|
+
),
|
|
187
|
+
maxPatternBars: Math.max(
|
|
188
|
+
4,
|
|
189
|
+
Math.floor(Number(config2.HEADSHOULDERS_MAX_PATTERN_BARS ?? 180))
|
|
190
|
+
),
|
|
191
|
+
minPatternSymmetryRatio: clampNumber({
|
|
192
|
+
value: config2.HEADSHOULDERS_MIN_PATTERN_SYMMETRY_RATIO,
|
|
193
|
+
fallback: 0.25,
|
|
194
|
+
min: 0,
|
|
195
|
+
max: 1
|
|
196
|
+
}),
|
|
197
|
+
maxNecklineSlopeRatio: clampNumber({
|
|
198
|
+
value: config2.HEADSHOULDERS_MAX_NECKLINE_SLOPE_RATIO,
|
|
199
|
+
fallback: 0.5,
|
|
200
|
+
min: 0
|
|
201
|
+
}),
|
|
202
|
+
maxPatternAgeBars: Math.max(
|
|
203
|
+
4,
|
|
204
|
+
Math.floor(Number(config2.HEADSHOULDERS_MAX_PATTERN_AGE_BARS ?? 220))
|
|
205
|
+
),
|
|
206
|
+
priorTrendLookback: Math.max(
|
|
207
|
+
1,
|
|
208
|
+
Math.floor(Number(config2.HEADSHOULDERS_PRIOR_TREND_LOOKBACK ?? 80))
|
|
209
|
+
),
|
|
210
|
+
maxPriorMoveAtr: clampNumber({
|
|
211
|
+
value: config2.HEADSHOULDERS_MAX_PRIOR_MOVE_ATR,
|
|
212
|
+
fallback: 0,
|
|
213
|
+
min: 0
|
|
214
|
+
}),
|
|
215
|
+
minBreakoutDistanceAtr: clampNumber({
|
|
216
|
+
value: config2.HEADSHOULDERS_MIN_BREAKOUT_DISTANCE_ATR,
|
|
217
|
+
fallback: 0.05,
|
|
218
|
+
min: 0
|
|
219
|
+
}),
|
|
220
|
+
maxBreakoutDistanceHeightRatio: clampNumber({
|
|
221
|
+
value: config2.HEADSHOULDERS_MAX_BREAKOUT_DISTANCE_HEIGHT_RATIO,
|
|
222
|
+
fallback: 0.35,
|
|
223
|
+
min: 0
|
|
224
|
+
}),
|
|
225
|
+
maxBreakoutDistancePct: clampNumber({
|
|
226
|
+
value: config2.HEADSHOULDERS_MAX_BREAKOUT_DISTANCE_PCT,
|
|
227
|
+
fallback: 0.8,
|
|
228
|
+
min: 0
|
|
229
|
+
}),
|
|
230
|
+
maxBreakoutDelayBars: Math.max(
|
|
231
|
+
0,
|
|
232
|
+
Math.floor(Number(config2.HEADSHOULDERS_MAX_BREAKOUT_DELAY_BARS ?? 0))
|
|
233
|
+
),
|
|
234
|
+
requireBreakoutCross: Boolean(config2.HEADSHOULDERS_REQUIRE_BREAKOUT_CROSS),
|
|
235
|
+
entryMode: config2.HEADSHOULDERS_ENTRY_MODE ?? "close_acceptance",
|
|
236
|
+
confirmationMaxBars: Math.max(
|
|
237
|
+
1,
|
|
238
|
+
Math.floor(Number(config2.HEADSHOULDERS_CONFIRMATION_MAX_BARS ?? 2))
|
|
239
|
+
),
|
|
240
|
+
minConfirmationBodyAtr: clampNumber({
|
|
241
|
+
value: config2.HEADSHOULDERS_MIN_CONFIRMATION_BODY_ATR,
|
|
242
|
+
fallback: 0,
|
|
243
|
+
min: 0
|
|
244
|
+
}),
|
|
245
|
+
maxConfirmationCloseLocation: clampNumber({
|
|
246
|
+
value: config2.HEADSHOULDERS_MAX_CONFIRMATION_CLOSE_LOCATION,
|
|
247
|
+
fallback: 1,
|
|
248
|
+
min: 0,
|
|
249
|
+
max: 1
|
|
250
|
+
}),
|
|
251
|
+
confirmationVolumePeriod: Math.max(
|
|
252
|
+
2,
|
|
253
|
+
Math.floor(Number(config2.HEADSHOULDERS_CONFIRMATION_VOLUME_PERIOD ?? 20))
|
|
254
|
+
),
|
|
255
|
+
minConfirmationVolumeRel: clampNumber({
|
|
256
|
+
value: config2.HEADSHOULDERS_MIN_CONFIRMATION_VOLUME_REL,
|
|
257
|
+
fallback: 0,
|
|
258
|
+
min: 0
|
|
259
|
+
}),
|
|
260
|
+
retestMaxBars: Math.max(
|
|
261
|
+
1,
|
|
262
|
+
Math.floor(Number(config2.HEADSHOULDERS_RETEST_MAX_BARS ?? 4))
|
|
263
|
+
),
|
|
264
|
+
retestToleranceAtr: clampNumber({
|
|
265
|
+
value: config2.HEADSHOULDERS_RETEST_TOLERANCE_ATR,
|
|
266
|
+
fallback: 0.25,
|
|
267
|
+
min: 0
|
|
268
|
+
})
|
|
269
|
+
});
|
|
270
|
+
var calculateAtr = (candles, period) => {
|
|
271
|
+
const relevant = candles.slice(-(period + 1));
|
|
272
|
+
if (relevant.length < 2) return null;
|
|
273
|
+
const trueRanges = [];
|
|
274
|
+
for (let index = 1; index < relevant.length; index += 1) {
|
|
275
|
+
const candle = relevant[index];
|
|
276
|
+
const previous = relevant[index - 1];
|
|
277
|
+
const high = asNumber(candle?.high);
|
|
278
|
+
const low = asNumber(candle?.low);
|
|
279
|
+
const previousClose = asNumber(previous?.close);
|
|
280
|
+
if (high == null || low == null || previousClose == null) continue;
|
|
281
|
+
trueRanges.push(
|
|
282
|
+
Math.max(
|
|
283
|
+
high - low,
|
|
284
|
+
Math.abs(high - previousClose),
|
|
285
|
+
Math.abs(low - previousClose)
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (trueRanges.length === 0) return null;
|
|
290
|
+
return trueRanges.reduce((sum, value) => sum + value, 0) / trueRanges.length;
|
|
291
|
+
};
|
|
292
|
+
var pushBoundedCandle = (state, candle, maxCandles) => {
|
|
293
|
+
state.currentIndex += 1;
|
|
294
|
+
state.candles.push(candle);
|
|
295
|
+
if (state.candles.length > maxCandles) {
|
|
296
|
+
const overflow = state.candles.length - maxCandles;
|
|
297
|
+
state.candles.splice(0, overflow);
|
|
298
|
+
state.candleStartIndex += overflow;
|
|
299
|
+
}
|
|
300
|
+
return state.currentIndex;
|
|
301
|
+
};
|
|
302
|
+
var getBufferedCandle = (state, absoluteIndex) => state.candles[absoluteIndex - state.candleStartIndex] ?? null;
|
|
303
|
+
var calculateAtrAt = ({
|
|
304
|
+
state,
|
|
305
|
+
absoluteIndex,
|
|
306
|
+
period
|
|
307
|
+
}) => {
|
|
308
|
+
const trueRanges = [];
|
|
309
|
+
for (let index = absoluteIndex - period + 1; index <= absoluteIndex; index += 1) {
|
|
310
|
+
const candle = getBufferedCandle(state, index);
|
|
311
|
+
const previous = getBufferedCandle(state, index - 1);
|
|
312
|
+
const high = asNumber(candle?.high);
|
|
313
|
+
const low = asNumber(candle?.low);
|
|
314
|
+
const previousClose = asNumber(previous?.close);
|
|
315
|
+
if (high == null || low == null || previousClose == null) continue;
|
|
316
|
+
trueRanges.push(
|
|
317
|
+
Math.max(
|
|
318
|
+
high - low,
|
|
319
|
+
Math.abs(high - previousClose),
|
|
320
|
+
Math.abs(low - previousClose)
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (trueRanges.length === 0) return null;
|
|
325
|
+
return trueRanges.reduce((sum, value) => sum + value, 0) / trueRanges.length;
|
|
326
|
+
};
|
|
327
|
+
var resolveConfirmedPivot = ({
|
|
328
|
+
state,
|
|
329
|
+
candidateIndex,
|
|
330
|
+
lookback,
|
|
331
|
+
atrPeriod,
|
|
332
|
+
priorTrendLookback
|
|
333
|
+
}) => {
|
|
334
|
+
const candidate = getBufferedCandle(state, candidateIndex);
|
|
335
|
+
const high = asNumber(candidate?.high);
|
|
336
|
+
const low = asNumber(candidate?.low);
|
|
337
|
+
if (!candidate || high == null || low == null) return null;
|
|
338
|
+
const window = [];
|
|
339
|
+
for (let index = candidateIndex - lookback; index <= candidateIndex + lookback; index += 1) {
|
|
340
|
+
const candle = getBufferedCandle(state, index);
|
|
341
|
+
if (!candle) return null;
|
|
342
|
+
window.push(candle);
|
|
343
|
+
}
|
|
344
|
+
const otherCandles = window.filter((_, index) => index !== lookback);
|
|
345
|
+
const isHigh = otherCandles.every((candle) => high >= Number(candle.high)) && otherCandles.some((candle) => high > Number(candle.high));
|
|
346
|
+
const isLow = otherCandles.every((candle) => low <= Number(candle.low)) && otherCandles.some((candle) => low < Number(candle.low));
|
|
347
|
+
if (isHigh === isLow) return null;
|
|
348
|
+
const atr = calculateAtrAt({
|
|
349
|
+
state,
|
|
350
|
+
absoluteIndex: candidateIndex,
|
|
351
|
+
period: atrPeriod
|
|
352
|
+
});
|
|
353
|
+
const priorClose = asNumber(
|
|
354
|
+
getBufferedCandle(state, candidateIndex - priorTrendLookback)?.close
|
|
355
|
+
);
|
|
356
|
+
const priorMove = priorClose == null ? null : isHigh ? high - priorClose : priorClose - low;
|
|
357
|
+
return {
|
|
358
|
+
timestamp: candidate.timestamp,
|
|
359
|
+
index: candidateIndex,
|
|
360
|
+
value: isHigh ? high : low,
|
|
361
|
+
kind: isHigh ? "high" : "low",
|
|
362
|
+
atr,
|
|
363
|
+
priorMoveAtr: priorMove != null && atr != null && atr > 0 ? priorMove / atr : null
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
var recordPivot = (state, pivot) => {
|
|
367
|
+
if (!pivot) return;
|
|
368
|
+
const latest = state.pivots[state.pivots.length - 1];
|
|
369
|
+
if (!latest || latest.kind !== pivot.kind) {
|
|
370
|
+
state.pivots.push(pivot);
|
|
371
|
+
if (state.pivots.length > 20) state.pivots.shift();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const isMoreExtreme = pivot.kind === "high" ? pivot.value >= latest.value : pivot.value <= latest.value;
|
|
375
|
+
if (isMoreExtreme) state.pivots[state.pivots.length - 1] = pivot;
|
|
376
|
+
};
|
|
377
|
+
var lineValueAt = ({
|
|
378
|
+
left,
|
|
379
|
+
right,
|
|
380
|
+
index
|
|
381
|
+
}) => {
|
|
382
|
+
const duration = right.index - left.index;
|
|
383
|
+
if (duration <= 0) return left.value;
|
|
384
|
+
return left.value + (right.value - left.value) / duration * (index - left.index);
|
|
385
|
+
};
|
|
386
|
+
var clonePattern = (pattern) => pattern ? { ...pattern, pivots: [...pattern.pivots] } : null;
|
|
387
|
+
var clonePending = (pending) => pending ? {
|
|
388
|
+
...pending,
|
|
389
|
+
pattern: clonePattern(pending.pattern)
|
|
390
|
+
} : null;
|
|
391
|
+
var rememberConsumed = (state, setupId) => {
|
|
392
|
+
if (state.consumedSetupIds.includes(setupId)) return;
|
|
393
|
+
state.consumedSetupIds.push(setupId);
|
|
394
|
+
if (state.consumedSetupIds.length > 64) state.consumedSetupIds.shift();
|
|
395
|
+
};
|
|
396
|
+
var isBeyondNeckline = (direction, close, neckline, minimumDistance) => direction === "LONG" ? close >= neckline + minimumDistance : close <= neckline - minimumDistance;
|
|
397
|
+
var buildBreakoutPattern = ({
|
|
398
|
+
state,
|
|
399
|
+
candle,
|
|
400
|
+
prevClose,
|
|
401
|
+
atr,
|
|
402
|
+
direction,
|
|
403
|
+
options
|
|
404
|
+
}) => {
|
|
405
|
+
if (state.pivots.length < 5) return null;
|
|
406
|
+
const pivots = state.pivots.slice(-5);
|
|
407
|
+
const [leftShoulder, leftNeck, head, rightNeck, rightShoulder] = pivots;
|
|
408
|
+
const expectedKinds = direction === "SHORT" ? ["high", "low", "high", "low", "high"] : ["low", "high", "low", "high", "low"];
|
|
409
|
+
if (pivots.some((pivot, index) => pivot.kind !== expectedKinds[index])) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const close = asNumber(candle.close);
|
|
413
|
+
if (close == null) return null;
|
|
414
|
+
const necklineAtHead = lineValueAt({
|
|
415
|
+
left: leftNeck,
|
|
416
|
+
right: rightNeck,
|
|
417
|
+
index: head.index
|
|
418
|
+
});
|
|
419
|
+
const directionMultiplier = direction === "LONG" ? 1 : -1;
|
|
420
|
+
const headHeight = direction === "LONG" ? necklineAtHead - head.value : head.value - necklineAtHead;
|
|
421
|
+
if (headHeight <= 0) return null;
|
|
422
|
+
const headHeightPct = necklineAtHead !== 0 ? headHeight / Math.abs(necklineAtHead) * 100 : 0;
|
|
423
|
+
const headHeightAtr = atr != null && atr > 0 ? headHeight / atr : 0;
|
|
424
|
+
const leftHeadProminence = direction === "LONG" ? leftShoulder.value - head.value : head.value - leftShoulder.value;
|
|
425
|
+
const rightHeadProminence = direction === "LONG" ? rightShoulder.value - head.value : head.value - rightShoulder.value;
|
|
426
|
+
const leftHeadProminenceRatio = leftHeadProminence / headHeight;
|
|
427
|
+
const rightHeadProminenceRatio = rightHeadProminence / headHeight;
|
|
428
|
+
const shoulderDifferencePct = Math.abs(leftShoulder.value - rightShoulder.value) / headHeight * 100;
|
|
429
|
+
if (headHeightPct < options.minHeadHeightPct || headHeightAtr < options.minHeadHeightAtr || leftHeadProminenceRatio < options.minHeadProminenceRatio || rightHeadProminenceRatio < options.minHeadProminenceRatio || shoulderDifferencePct > options.shoulderTolerancePct) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
const leftShoulderNeckline = lineValueAt({
|
|
433
|
+
left: leftNeck,
|
|
434
|
+
right: rightNeck,
|
|
435
|
+
index: leftShoulder.index
|
|
436
|
+
});
|
|
437
|
+
const rightShoulderNeckline = lineValueAt({
|
|
438
|
+
left: leftNeck,
|
|
439
|
+
right: rightNeck,
|
|
440
|
+
index: rightShoulder.index
|
|
441
|
+
});
|
|
442
|
+
const shouldersRemainOnPatternSide = direction === "LONG" ? leftShoulder.value < leftShoulderNeckline && rightShoulder.value < rightShoulderNeckline : leftShoulder.value > leftShoulderNeckline && rightShoulder.value > rightShoulderNeckline;
|
|
443
|
+
if (!shouldersRemainOnPatternSide) return null;
|
|
444
|
+
const patternDurationBars = rightShoulder.index - leftShoulder.index;
|
|
445
|
+
const leftHalfBars = head.index - leftShoulder.index;
|
|
446
|
+
const rightHalfBars = rightShoulder.index - head.index;
|
|
447
|
+
const patternSymmetryRatio = Math.max(leftHalfBars, rightHalfBars) > 0 ? Math.min(leftHalfBars, rightHalfBars) / Math.max(leftHalfBars, rightHalfBars) : 0;
|
|
448
|
+
const patternAgeBars = state.currentIndex - leftShoulder.index;
|
|
449
|
+
const breakoutDelayBars = state.currentIndex - rightShoulder.index;
|
|
450
|
+
const necklineSlopePerBar = (rightNeck.value - leftNeck.value) / (rightNeck.index - leftNeck.index);
|
|
451
|
+
const necklineSlopeRatio = Math.abs(rightNeck.value - leftNeck.value) / headHeight;
|
|
452
|
+
const priorMoveAtr = leftShoulder.priorMoveAtr ?? null;
|
|
453
|
+
if (patternDurationBars < options.minPatternBars || patternDurationBars > options.maxPatternBars || patternSymmetryRatio < options.minPatternSymmetryRatio || necklineSlopeRatio > options.maxNecklineSlopeRatio || patternAgeBars > options.maxPatternAgeBars || options.maxBreakoutDelayBars > 0 && breakoutDelayBars > options.maxBreakoutDelayBars || options.maxPriorMoveAtr > 0 && (priorMoveAtr == null || priorMoveAtr > options.maxPriorMoveAtr)) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const neckline = lineValueAt({
|
|
457
|
+
left: leftNeck,
|
|
458
|
+
right: rightNeck,
|
|
459
|
+
index: state.currentIndex
|
|
460
|
+
});
|
|
461
|
+
const breakoutDistance = Math.abs(close - neckline);
|
|
462
|
+
const breakoutDistancePct = neckline !== 0 ? breakoutDistance / Math.abs(neckline) * 100 : 0;
|
|
463
|
+
const breakoutDistanceAtr = atr != null && atr > 0 ? breakoutDistance / atr : 0;
|
|
464
|
+
const breakoutDistanceHeightRatio = breakoutDistance / headHeight;
|
|
465
|
+
if (!isBeyondNeckline(direction, close, neckline, 0) || breakoutDistanceAtr < options.minBreakoutDistanceAtr || options.maxBreakoutDistanceHeightRatio > 0 && breakoutDistanceHeightRatio > options.maxBreakoutDistanceHeightRatio || options.maxBreakoutDistancePct > 0 && breakoutDistancePct > options.maxBreakoutDistancePct) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const previousNeckline = lineValueAt({
|
|
469
|
+
left: leftNeck,
|
|
470
|
+
right: rightNeck,
|
|
471
|
+
index: state.currentIndex - 1
|
|
472
|
+
});
|
|
473
|
+
const breakoutCrossedOnSignalBar = prevClose != null && (direction === "LONG" ? prevClose <= previousNeckline && close > neckline : prevClose >= previousNeckline && close < neckline);
|
|
474
|
+
if (options.requireBreakoutCross && !breakoutCrossedOnSignalBar) return null;
|
|
475
|
+
const kind = direction === "LONG" ? "inverse_head_and_shoulders" : "head_and_shoulders";
|
|
476
|
+
const setupId = `${kind}:${pivots.map((pivot) => pivot.timestamp).join(":")}`;
|
|
477
|
+
if (state.consumedSetupIds.includes(setupId)) return null;
|
|
478
|
+
return {
|
|
479
|
+
setupId,
|
|
480
|
+
kind,
|
|
481
|
+
direction,
|
|
482
|
+
entryMode: options.entryMode,
|
|
483
|
+
entryStage: "breakout",
|
|
484
|
+
pivots,
|
|
485
|
+
neckline,
|
|
486
|
+
necklineSlopePerBar,
|
|
487
|
+
targetPrice: neckline + directionMultiplier * headHeight * ((direction === "LONG" ? options.targetHeightPctLong : options.targetHeightPctShort) / 100),
|
|
488
|
+
stopLossPrice: head.value - directionMultiplier * headHeight * (options.stopBufferHeightPct / 100),
|
|
489
|
+
headHeight,
|
|
490
|
+
headHeightPct,
|
|
491
|
+
headHeightAtr,
|
|
492
|
+
shoulderDifferencePct,
|
|
493
|
+
leftHeadProminenceRatio,
|
|
494
|
+
rightHeadProminenceRatio,
|
|
495
|
+
patternDurationBars,
|
|
496
|
+
patternSymmetryRatio,
|
|
497
|
+
patternAgeBars,
|
|
498
|
+
necklineSlopeRatio,
|
|
499
|
+
priorMoveAtr,
|
|
500
|
+
breakoutDistancePct,
|
|
501
|
+
breakoutDistanceAtr,
|
|
502
|
+
breakoutDistanceHeightRatio,
|
|
503
|
+
breakoutDelayBars,
|
|
504
|
+
breakoutCrossedOnSignalBar,
|
|
505
|
+
breakoutTimestamp: candle.timestamp,
|
|
506
|
+
confirmationBars: 0,
|
|
507
|
+
confirmationBodyAtr: null,
|
|
508
|
+
confirmationCloseLocation: null,
|
|
509
|
+
confirmationVolumeRel: null,
|
|
510
|
+
timestamp: candle.timestamp,
|
|
511
|
+
close
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
var getPatternNecklineAt = (pattern, index) => {
|
|
515
|
+
const leftNeck = pattern.pivots[1];
|
|
516
|
+
return leftNeck.value + pattern.necklineSlopePerBar * (index - leftNeck.index);
|
|
517
|
+
};
|
|
518
|
+
var resolvePending = ({
|
|
519
|
+
state,
|
|
520
|
+
candle,
|
|
521
|
+
atr,
|
|
522
|
+
options
|
|
523
|
+
}) => {
|
|
524
|
+
const pending = state.pending;
|
|
525
|
+
if (!pending) return null;
|
|
526
|
+
const confirmationBars = state.currentIndex - pending.breakoutIndex;
|
|
527
|
+
if (confirmationBars < 1) return null;
|
|
528
|
+
const close = asNumber(candle.close);
|
|
529
|
+
const open = asNumber(candle.open);
|
|
530
|
+
const high = asNumber(candle.high);
|
|
531
|
+
const low = asNumber(candle.low);
|
|
532
|
+
if (close == null || open == null || high == null || low == null) return null;
|
|
533
|
+
const pattern = pending.pattern;
|
|
534
|
+
const invalidated = pattern.direction === "LONG" ? low <= pattern.stopLossPrice : high >= pattern.stopLossPrice;
|
|
535
|
+
const maxBars = pending.mode === "retest" ? options.retestMaxBars : options.confirmationMaxBars;
|
|
536
|
+
if (invalidated || confirmationBars > maxBars) {
|
|
537
|
+
rememberConsumed(state, pending.setupId);
|
|
538
|
+
state.pending = null;
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const neckline = getPatternNecklineAt(pattern, state.currentIndex);
|
|
542
|
+
const effectiveAtr = atr != null && atr > 0 ? atr : pattern.headHeight;
|
|
543
|
+
const minimumDistance = effectiveAtr * options.minBreakoutDistanceAtr;
|
|
544
|
+
const closeAccepted = isBeyondNeckline(
|
|
545
|
+
pattern.direction,
|
|
546
|
+
close,
|
|
547
|
+
neckline,
|
|
548
|
+
minimumDistance
|
|
549
|
+
);
|
|
550
|
+
const confirmationBody = pattern.direction === "LONG" ? close - open : open - close;
|
|
551
|
+
const confirmationBodyAtr = effectiveAtr > 0 ? confirmationBody / effectiveAtr : 0;
|
|
552
|
+
const candleRange = high - low;
|
|
553
|
+
const confirmationCloseLocation = candleRange > 0 ? pattern.direction === "LONG" ? (high - close) / candleRange : (close - low) / candleRange : 1;
|
|
554
|
+
const priorVolumeCandles = state.candles.slice(
|
|
555
|
+
-(options.confirmationVolumePeriod + 1),
|
|
556
|
+
-1
|
|
557
|
+
);
|
|
558
|
+
const priorVolumes = priorVolumeCandles.map((item) => asNumber(item.volume)).filter((value) => value != null && value >= 0);
|
|
559
|
+
const averagePriorVolume = priorVolumes.length > 0 ? priorVolumes.reduce((sum, value) => sum + value, 0) / priorVolumes.length : null;
|
|
560
|
+
const currentVolume = asNumber(candle.volume);
|
|
561
|
+
const confirmationVolumeRel = currentVolume != null && averagePriorVolume != null && averagePriorVolume > 0 ? currentVolume / averagePriorVolume : null;
|
|
562
|
+
const confirmationQualityAccepted = (options.minConfirmationBodyAtr <= 0 || confirmationBodyAtr >= options.minConfirmationBodyAtr) && confirmationCloseLocation <= options.maxConfirmationCloseLocation && (options.minConfirmationVolumeRel <= 0 || confirmationVolumeRel != null && confirmationVolumeRel >= options.minConfirmationVolumeRel);
|
|
563
|
+
let entryStage = null;
|
|
564
|
+
if (pending.mode === "close_acceptance") {
|
|
565
|
+
if (closeAccepted && confirmationQualityAccepted) {
|
|
566
|
+
entryStage = "close_accepted";
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
const tolerance = effectiveAtr * options.retestToleranceAtr;
|
|
570
|
+
const touched = pattern.direction === "LONG" ? low <= neckline + tolerance && low >= neckline - tolerance : high >= neckline - tolerance && high <= neckline + tolerance;
|
|
571
|
+
if (touched && closeAccepted && confirmationQualityAccepted) {
|
|
572
|
+
entryStage = "retest_held";
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!entryStage) return null;
|
|
576
|
+
rememberConsumed(state, pending.setupId);
|
|
577
|
+
state.pending = null;
|
|
578
|
+
return {
|
|
579
|
+
...pattern,
|
|
580
|
+
entryStage,
|
|
581
|
+
neckline,
|
|
582
|
+
confirmationBars,
|
|
583
|
+
confirmationBodyAtr,
|
|
584
|
+
confirmationCloseLocation,
|
|
585
|
+
confirmationVolumeRel,
|
|
586
|
+
timestamp: candle.timestamp,
|
|
587
|
+
close
|
|
588
|
+
};
|
|
589
|
+
};
|
|
590
|
+
var buildHeadAndShouldersSignalContext = (pattern) => ({
|
|
591
|
+
setupId: pattern.setupId,
|
|
592
|
+
patternKind: pattern.kind,
|
|
593
|
+
signalDirection: pattern.direction,
|
|
594
|
+
entryMode: pattern.entryMode,
|
|
595
|
+
entryStage: pattern.entryStage,
|
|
596
|
+
neckline: pattern.neckline,
|
|
597
|
+
necklineSlopePerBar: pattern.necklineSlopePerBar,
|
|
598
|
+
targetPrice: pattern.targetPrice,
|
|
599
|
+
stopLossPrice: pattern.stopLossPrice,
|
|
600
|
+
headHeight: pattern.headHeight,
|
|
601
|
+
headHeightPct: pattern.headHeightPct,
|
|
602
|
+
headHeightAtr: pattern.headHeightAtr,
|
|
603
|
+
shoulderDifferencePct: pattern.shoulderDifferencePct,
|
|
604
|
+
leftHeadProminenceRatio: pattern.leftHeadProminenceRatio,
|
|
605
|
+
rightHeadProminenceRatio: pattern.rightHeadProminenceRatio,
|
|
606
|
+
patternDurationBars: pattern.patternDurationBars,
|
|
607
|
+
patternSymmetryRatio: pattern.patternSymmetryRatio,
|
|
608
|
+
patternAgeBars: pattern.patternAgeBars,
|
|
609
|
+
necklineSlopeRatio: pattern.necklineSlopeRatio,
|
|
610
|
+
priorMoveAtr: pattern.priorMoveAtr,
|
|
611
|
+
breakoutDistancePct: pattern.breakoutDistancePct,
|
|
612
|
+
breakoutDistanceAtr: pattern.breakoutDistanceAtr,
|
|
613
|
+
breakoutDistanceHeightRatio: pattern.breakoutDistanceHeightRatio,
|
|
614
|
+
breakoutDelayBars: pattern.breakoutDelayBars,
|
|
615
|
+
breakoutCrossedOnSignalBar: pattern.breakoutCrossedOnSignalBar,
|
|
616
|
+
breakoutTimestamp: pattern.breakoutTimestamp,
|
|
617
|
+
confirmationBars: pattern.confirmationBars,
|
|
618
|
+
confirmationBodyAtr: pattern.confirmationBodyAtr,
|
|
619
|
+
confirmationCloseLocation: pattern.confirmationCloseLocation,
|
|
620
|
+
confirmationVolumeRel: pattern.confirmationVolumeRel,
|
|
621
|
+
currentPrice: pattern.close,
|
|
622
|
+
pivots: pattern.pivots.map(({ timestamp, value, kind }) => ({
|
|
623
|
+
timestamp,
|
|
624
|
+
value,
|
|
625
|
+
kind
|
|
626
|
+
}))
|
|
627
|
+
});
|
|
628
|
+
var createHeadAndShouldersEngine = ({
|
|
629
|
+
config: config2,
|
|
630
|
+
initialCandles = []
|
|
631
|
+
}) => {
|
|
632
|
+
const options = getConfigNumbers(config2);
|
|
633
|
+
const state = {
|
|
634
|
+
candles: [],
|
|
635
|
+
candleStartIndex: 0,
|
|
636
|
+
currentIndex: -1,
|
|
637
|
+
pivots: [],
|
|
638
|
+
pattern: null,
|
|
639
|
+
pending: null,
|
|
640
|
+
consumedSetupIds: [],
|
|
641
|
+
lastTimestamp: null
|
|
642
|
+
};
|
|
643
|
+
const snapshot = () => ({
|
|
644
|
+
pattern: clonePattern(state.pattern),
|
|
645
|
+
pending: clonePending(state.pending),
|
|
646
|
+
pivots: state.pivots.map((pivot) => ({ ...pivot }))
|
|
647
|
+
});
|
|
648
|
+
const apply = (candle) => {
|
|
649
|
+
if (state.lastTimestamp === candle.timestamp) return snapshot();
|
|
650
|
+
state.lastTimestamp = candle.timestamp;
|
|
651
|
+
state.pattern = null;
|
|
652
|
+
const previous = state.candles[state.candles.length - 1];
|
|
653
|
+
const prevClose = previous ? asNumber(previous.close) : null;
|
|
654
|
+
const maxCandles = Math.max(
|
|
655
|
+
options.pivotLookback * 2 + 1,
|
|
656
|
+
options.atrPeriod + 1,
|
|
657
|
+
options.priorTrendLookback + options.pivotLookback + 1,
|
|
658
|
+
options.confirmationVolumePeriod + 1
|
|
659
|
+
);
|
|
660
|
+
const currentIndex = pushBoundedCandle(state, candle, maxCandles);
|
|
661
|
+
const atr = calculateAtr(state.candles, options.atrPeriod);
|
|
662
|
+
const pendingPattern = resolvePending({ state, candle, atr, options });
|
|
663
|
+
recordPivot(
|
|
664
|
+
state,
|
|
665
|
+
resolveConfirmedPivot({
|
|
666
|
+
state,
|
|
667
|
+
candidateIndex: currentIndex - options.pivotLookback,
|
|
668
|
+
lookback: options.pivotLookback,
|
|
669
|
+
atrPeriod: options.atrPeriod,
|
|
670
|
+
priorTrendLookback: options.priorTrendLookback
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
if (pendingPattern) {
|
|
674
|
+
state.pattern = pendingPattern;
|
|
675
|
+
return snapshot();
|
|
676
|
+
}
|
|
677
|
+
if (state.pending) return snapshot();
|
|
678
|
+
const breakout = buildBreakoutPattern({
|
|
679
|
+
state,
|
|
680
|
+
candle,
|
|
681
|
+
prevClose,
|
|
682
|
+
atr,
|
|
683
|
+
direction: "SHORT",
|
|
684
|
+
options
|
|
685
|
+
}) ?? buildBreakoutPattern({
|
|
686
|
+
state,
|
|
687
|
+
candle,
|
|
688
|
+
prevClose,
|
|
689
|
+
atr,
|
|
690
|
+
direction: "LONG",
|
|
691
|
+
options
|
|
692
|
+
});
|
|
693
|
+
if (!breakout) return snapshot();
|
|
694
|
+
if (options.entryMode === "breakout") {
|
|
695
|
+
rememberConsumed(state, breakout.setupId);
|
|
696
|
+
state.pattern = breakout;
|
|
697
|
+
return snapshot();
|
|
698
|
+
}
|
|
699
|
+
state.pending = {
|
|
700
|
+
setupId: breakout.setupId,
|
|
701
|
+
mode: options.entryMode,
|
|
702
|
+
stage: options.entryMode === "retest" ? "retest_pending" : "neckline_crossed",
|
|
703
|
+
breakoutIndex: currentIndex,
|
|
704
|
+
pattern: breakout
|
|
705
|
+
};
|
|
706
|
+
return snapshot();
|
|
707
|
+
};
|
|
708
|
+
for (const candle of initialCandles) apply(candle);
|
|
709
|
+
return { next: apply, getState: snapshot };
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// src/HeadAndShoulders/figures.ts
|
|
713
|
+
var buildHeadAndShouldersFigures = ({
|
|
714
|
+
pattern,
|
|
715
|
+
entryTimestamp,
|
|
716
|
+
entryPrice
|
|
717
|
+
}) => {
|
|
718
|
+
const color = pattern.direction === "LONG" ? "#22c55e" : "#ef4444";
|
|
719
|
+
const [leftShoulder, leftNeck, head, rightNeck, rightShoulder] = pattern.pivots;
|
|
720
|
+
const patternPoints = [
|
|
721
|
+
leftShoulder,
|
|
722
|
+
leftNeck,
|
|
723
|
+
head,
|
|
724
|
+
rightNeck,
|
|
725
|
+
rightShoulder
|
|
726
|
+
].map(({ timestamp, value }) => ({ timestamp, value }));
|
|
727
|
+
const lines = [
|
|
728
|
+
{
|
|
729
|
+
id: `headshoulders-pattern-${entryTimestamp}`,
|
|
730
|
+
kind: `headshoulders_${pattern.kind}_pattern`,
|
|
731
|
+
points: [
|
|
732
|
+
...patternPoints,
|
|
733
|
+
{ timestamp: entryTimestamp, value: entryPrice }
|
|
734
|
+
],
|
|
735
|
+
color,
|
|
736
|
+
width: 2,
|
|
737
|
+
style: "solid"
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
id: `headshoulders-neckline-${entryTimestamp}`,
|
|
741
|
+
kind: "headshoulders_neckline",
|
|
742
|
+
points: [
|
|
743
|
+
{ timestamp: leftNeck.timestamp, value: leftNeck.value },
|
|
744
|
+
{ timestamp: entryTimestamp, value: pattern.neckline }
|
|
745
|
+
],
|
|
746
|
+
color: "#f59e0b",
|
|
747
|
+
width: 2,
|
|
748
|
+
style: "dashed"
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: `headshoulders-target-${entryTimestamp}`,
|
|
752
|
+
kind: "headshoulders_target",
|
|
753
|
+
points: [
|
|
754
|
+
{ timestamp: rightNeck.timestamp, value: pattern.targetPrice },
|
|
755
|
+
{ timestamp: entryTimestamp, value: pattern.targetPrice }
|
|
756
|
+
],
|
|
757
|
+
color: "#22c55e",
|
|
758
|
+
width: 1,
|
|
759
|
+
style: "dashed"
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
id: `headshoulders-stop-${entryTimestamp}`,
|
|
763
|
+
kind: "headshoulders_stop",
|
|
764
|
+
points: [
|
|
765
|
+
{ timestamp: head.timestamp, value: pattern.stopLossPrice },
|
|
766
|
+
{ timestamp: entryTimestamp, value: pattern.stopLossPrice }
|
|
767
|
+
],
|
|
768
|
+
color: "#ef4444",
|
|
769
|
+
width: 1,
|
|
770
|
+
style: "dashed"
|
|
771
|
+
}
|
|
772
|
+
];
|
|
773
|
+
const points = [
|
|
774
|
+
{
|
|
775
|
+
id: `headshoulders-pivots-${entryTimestamp}`,
|
|
776
|
+
kind: `headshoulders_${pattern.kind}_pivots`,
|
|
777
|
+
points: patternPoints,
|
|
778
|
+
color,
|
|
779
|
+
radius: 4
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
id: `headshoulders-entry-${entryTimestamp}`,
|
|
783
|
+
kind: "headshoulders_entry",
|
|
784
|
+
points: [{ timestamp: entryTimestamp, value: entryPrice }],
|
|
785
|
+
color,
|
|
786
|
+
radius: 5
|
|
787
|
+
}
|
|
788
|
+
];
|
|
789
|
+
return { lines, points };
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
// src/HeadAndShoulders/filters.ts
|
|
793
|
+
var import_config2 = require("@tradejs/strategy-kit/config");
|
|
794
|
+
var asPositiveThreshold = (value) => {
|
|
795
|
+
const parsed = Number(value);
|
|
796
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
797
|
+
};
|
|
798
|
+
var getHeadAndShouldersCoreFilterSkipCode = ({
|
|
799
|
+
pattern,
|
|
800
|
+
config: config2,
|
|
801
|
+
baseContext
|
|
802
|
+
}) => {
|
|
803
|
+
const direction = pattern.direction;
|
|
804
|
+
const minBodyStrength = asPositiveThreshold(
|
|
805
|
+
(0, import_config2.resolveDirectionalConfigNumber)({
|
|
806
|
+
config: config2,
|
|
807
|
+
key: "HEADSHOULDERS_MIN_SIGNAL_BODY_STRENGTH",
|
|
808
|
+
direction,
|
|
809
|
+
fallback: 0
|
|
810
|
+
})
|
|
811
|
+
);
|
|
812
|
+
if (minBodyStrength != null) {
|
|
813
|
+
const bodyStrength = Number(baseContext?.regime?.momentum?.bodyStrength);
|
|
814
|
+
if (!Number.isFinite(bodyStrength) || bodyStrength < minBodyStrength) {
|
|
815
|
+
return "HEADSHOULDERS_SIGNAL_BODY_TOO_WEAK";
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const minHeadHeightAtr = asPositiveThreshold(
|
|
819
|
+
(0, import_config2.resolveDirectionalConfigNumber)({
|
|
820
|
+
config: config2,
|
|
821
|
+
key: "HEADSHOULDERS_MIN_ENTRY_HEAD_HEIGHT_ATR",
|
|
822
|
+
direction,
|
|
823
|
+
fallback: 0
|
|
824
|
+
})
|
|
825
|
+
);
|
|
826
|
+
if (minHeadHeightAtr != null && (!Number.isFinite(pattern.headHeightAtr) || pattern.headHeightAtr < minHeadHeightAtr)) {
|
|
827
|
+
return "HEADSHOULDERS_HEAD_TOO_SHALLOW_AT_ENTRY";
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// src/HeadAndShoulders/core.ts
|
|
833
|
+
var import_risk = require("@tradejs/strategy-kit/risk");
|
|
834
|
+
var isOpenPosition = (position) => Boolean(
|
|
835
|
+
position && typeof position.price === "number" && Number.isFinite(position.price) && typeof position.qty === "number" && Number.isFinite(position.qty) && position.qty > 0 && (position.direction === "LONG" || position.direction === "SHORT")
|
|
836
|
+
);
|
|
837
|
+
var buildHeadAndShouldersStateKey = (config2) => JSON.stringify({
|
|
838
|
+
pivotLookback: config2.HEADSHOULDERS_PIVOT_LOOKBACK,
|
|
839
|
+
shoulderTolerancePct: config2.HEADSHOULDERS_SHOULDER_TOLERANCE_PCT,
|
|
840
|
+
minHeadProminenceRatio: config2.HEADSHOULDERS_MIN_HEAD_PROMINENCE_RATIO,
|
|
841
|
+
targetHeightPct: config2.HEADSHOULDERS_TARGET_HEIGHT_PCT,
|
|
842
|
+
targetHeightPctLong: config2.HEADSHOULDERS_TARGET_HEIGHT_PCT_LONG,
|
|
843
|
+
targetHeightPctShort: config2.HEADSHOULDERS_TARGET_HEIGHT_PCT_SHORT,
|
|
844
|
+
stopBufferHeightPct: config2.HEADSHOULDERS_STOP_BUFFER_HEIGHT_PCT,
|
|
845
|
+
minHeadHeightPct: config2.HEADSHOULDERS_MIN_HEAD_HEIGHT_PCT,
|
|
846
|
+
minHeadHeightAtr: config2.HEADSHOULDERS_MIN_HEAD_HEIGHT_ATR,
|
|
847
|
+
atrPeriod: config2.HEADSHOULDERS_ATR_PERIOD,
|
|
848
|
+
minPatternBars: config2.HEADSHOULDERS_MIN_PATTERN_BARS,
|
|
849
|
+
maxPatternBars: config2.HEADSHOULDERS_MAX_PATTERN_BARS,
|
|
850
|
+
minPatternSymmetryRatio: config2.HEADSHOULDERS_MIN_PATTERN_SYMMETRY_RATIO,
|
|
851
|
+
maxNecklineSlopeRatio: config2.HEADSHOULDERS_MAX_NECKLINE_SLOPE_RATIO,
|
|
852
|
+
maxPatternAgeBars: config2.HEADSHOULDERS_MAX_PATTERN_AGE_BARS,
|
|
853
|
+
priorTrendLookback: config2.HEADSHOULDERS_PRIOR_TREND_LOOKBACK,
|
|
854
|
+
maxPriorMoveAtr: config2.HEADSHOULDERS_MAX_PRIOR_MOVE_ATR,
|
|
855
|
+
minBreakoutDistanceAtr: config2.HEADSHOULDERS_MIN_BREAKOUT_DISTANCE_ATR,
|
|
856
|
+
maxBreakoutDistanceHeightRatio: config2.HEADSHOULDERS_MAX_BREAKOUT_DISTANCE_HEIGHT_RATIO,
|
|
857
|
+
maxBreakoutDistancePct: config2.HEADSHOULDERS_MAX_BREAKOUT_DISTANCE_PCT,
|
|
858
|
+
maxBreakoutDelayBars: config2.HEADSHOULDERS_MAX_BREAKOUT_DELAY_BARS,
|
|
859
|
+
requireBreakoutCross: config2.HEADSHOULDERS_REQUIRE_BREAKOUT_CROSS,
|
|
860
|
+
entryMode: config2.HEADSHOULDERS_ENTRY_MODE,
|
|
861
|
+
confirmationMaxBars: config2.HEADSHOULDERS_CONFIRMATION_MAX_BARS,
|
|
862
|
+
minConfirmationBodyAtr: config2.HEADSHOULDERS_MIN_CONFIRMATION_BODY_ATR,
|
|
863
|
+
maxConfirmationCloseLocation: config2.HEADSHOULDERS_MAX_CONFIRMATION_CLOSE_LOCATION,
|
|
864
|
+
confirmationVolumePeriod: config2.HEADSHOULDERS_CONFIRMATION_VOLUME_PERIOD,
|
|
865
|
+
minConfirmationVolumeRel: config2.HEADSHOULDERS_MIN_CONFIRMATION_VOLUME_REL,
|
|
866
|
+
retestMaxBars: config2.HEADSHOULDERS_RETEST_MAX_BARS,
|
|
867
|
+
retestToleranceAtr: config2.HEADSHOULDERS_RETEST_TOLERANCE_ATR
|
|
868
|
+
});
|
|
869
|
+
var createHeadAndShouldersCore = async ({ config: config2, data: initialData, strategyApi, indicatorsState }) => {
|
|
870
|
+
const detectorState = strategyApi.createStateController(
|
|
871
|
+
"HeadAndShoulders",
|
|
872
|
+
() => ({
|
|
873
|
+
engine: createHeadAndShouldersEngine({
|
|
874
|
+
config: config2,
|
|
875
|
+
initialCandles: initialData
|
|
876
|
+
})
|
|
877
|
+
}),
|
|
878
|
+
{
|
|
879
|
+
configKey: buildHeadAndShouldersStateKey(config2),
|
|
880
|
+
snapshot: (state) => state.engine.getState()
|
|
881
|
+
}
|
|
882
|
+
);
|
|
883
|
+
const lastTradeController = strategyApi.createLastTradeController({
|
|
884
|
+
enabled: true
|
|
885
|
+
});
|
|
886
|
+
const nextDetectorState = (candle) => detectorState.oncePerTimestamp(
|
|
887
|
+
candle.timestamp,
|
|
888
|
+
(state) => state.engine.next(candle)
|
|
889
|
+
);
|
|
890
|
+
return async (candle) => {
|
|
891
|
+
const runtimeState = nextDetectorState(candle);
|
|
892
|
+
const pattern = runtimeState.pattern;
|
|
893
|
+
if (!pattern) return strategyApi.skip("NO_PATTERN");
|
|
894
|
+
const position = await strategyApi.getCurrentPosition();
|
|
895
|
+
if (isOpenPosition(position)) {
|
|
896
|
+
const oppositePattern = position.direction !== pattern.direction;
|
|
897
|
+
if (Boolean(config2.HEADSHOULDERS_EXIT_ON_OPPOSITE_PATTERN) && oppositePattern) {
|
|
898
|
+
return strategyApi.exit({
|
|
899
|
+
code: "HEADSHOULDERS_OPPOSITE_PATTERN_EXIT",
|
|
900
|
+
direction: position.direction
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return strategyApi.skip("POSITION_EXISTS");
|
|
904
|
+
}
|
|
905
|
+
if (lastTradeController.isInCooldown(candle.timestamp)) {
|
|
906
|
+
return strategyApi.skip("DEV_TRADE_COOLDOWN");
|
|
907
|
+
}
|
|
908
|
+
const sideConfig = pattern.direction === "LONG" ? config2.LONG : config2.SHORT;
|
|
909
|
+
if (!sideConfig.enable) return strategyApi.skip("STRATEGY_DISABLED");
|
|
910
|
+
const filterSkipCode = getHeadAndShouldersCoreFilterSkipCode({
|
|
911
|
+
pattern,
|
|
912
|
+
config: config2,
|
|
913
|
+
baseContext: strategyApi.getBaseContext()
|
|
914
|
+
});
|
|
915
|
+
if (filterSkipCode) return strategyApi.skip(filterSkipCode);
|
|
916
|
+
const { timestamp, currentPrice } = await strategyApi.getDecisionPriceContext();
|
|
917
|
+
if (!(0, import_risk.isStopLossOnCorrectSide)({
|
|
918
|
+
direction: pattern.direction,
|
|
919
|
+
currentPrice,
|
|
920
|
+
stopLossPrice: pattern.stopLossPrice
|
|
921
|
+
})) {
|
|
922
|
+
return strategyApi.skip("INVALID_STOP");
|
|
923
|
+
}
|
|
924
|
+
const targetIsValid = pattern.direction === "LONG" ? pattern.targetPrice > currentPrice : pattern.targetPrice < currentPrice;
|
|
925
|
+
if (!targetIsValid) return strategyApi.skip("TARGET_ALREADY_PASSED");
|
|
926
|
+
const economics = (0, import_risk.buildTradeEconomics)({
|
|
927
|
+
entryPrice: currentPrice,
|
|
928
|
+
stopLossPrice: pattern.stopLossPrice,
|
|
929
|
+
takeProfitPrice: pattern.targetPrice,
|
|
930
|
+
feeRate: Number(config2.FEE_PERCENT ?? 0),
|
|
931
|
+
slippageBps: Number(config2.SLIPPAGE_BASE_BPS ?? 0) + Number(config2.SLIPPAGE_MARKET_IMPACT_BPS ?? 0)
|
|
932
|
+
});
|
|
933
|
+
const qty = economics.lossPerUnit > 0 ? Number(config2.MAX_LOSS_VALUE ?? 0) / economics.lossPerUnit : 0;
|
|
934
|
+
const riskRatio = economics.netRiskRatio;
|
|
935
|
+
const signalContext = {
|
|
936
|
+
...buildHeadAndShouldersSignalContext({
|
|
937
|
+
...pattern,
|
|
938
|
+
close: currentPrice
|
|
939
|
+
}),
|
|
940
|
+
executionEconomics: {
|
|
941
|
+
grossRiskRatio: economics.grossRiskRatio,
|
|
942
|
+
netRiskRatio: economics.netRiskRatio,
|
|
943
|
+
lossPerUnit: economics.lossPerUnit,
|
|
944
|
+
rewardPerUnit: economics.rewardPerUnit
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
if (!qty || !Number.isFinite(qty) || qty <= 0) {
|
|
948
|
+
return strategyApi.skip("INVALID_QTY");
|
|
949
|
+
}
|
|
950
|
+
if (riskRatio <= sideConfig.minRiskRatio) {
|
|
951
|
+
return strategyApi.skip(`RISK_RATIO:${(0, import_math.round)(riskRatio)}`);
|
|
952
|
+
}
|
|
953
|
+
const indicators = indicatorsState.snapshot();
|
|
954
|
+
lastTradeController.markTrade(timestamp);
|
|
955
|
+
return strategyApi.entry({
|
|
956
|
+
code: pattern.direction === "LONG" ? `HEADSHOULDERS_INVERSE_${pattern.entryStage.toUpperCase()}` : `HEADSHOULDERS_CLASSIC_${pattern.entryStage.toUpperCase()}`,
|
|
957
|
+
direction: sideConfig.direction,
|
|
958
|
+
indicators,
|
|
959
|
+
additionalIndicators: {
|
|
960
|
+
headAndShouldersContext: signalContext
|
|
961
|
+
},
|
|
962
|
+
figures: buildHeadAndShouldersFigures({
|
|
963
|
+
pattern,
|
|
964
|
+
entryTimestamp: timestamp,
|
|
965
|
+
entryPrice: currentPrice
|
|
966
|
+
}),
|
|
967
|
+
orderPlan: {
|
|
968
|
+
qty,
|
|
969
|
+
stopLossPrice: pattern.stopLossPrice,
|
|
970
|
+
takeProfits: [{ rate: 1, price: pattern.targetPrice }]
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// src/HeadAndShoulders/adapters/ai.ts
|
|
977
|
+
var import_strategies = require("@tradejs/core/strategies");
|
|
978
|
+
var import_ai_gate = require("@tradejs/strategy-kit/ai-gate");
|
|
979
|
+
var asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
980
|
+
var getContext = (payload) => asRecord(
|
|
981
|
+
asRecord(payload.additionalIndicators).headAndShouldersContext
|
|
982
|
+
);
|
|
983
|
+
var headAndShouldersBaseAiAdapter = {
|
|
984
|
+
buildPayload: ({ signal, basePayload }) => ({
|
|
985
|
+
...basePayload,
|
|
986
|
+
additionalIndicators: {
|
|
987
|
+
...asRecord(basePayload.additionalIndicators),
|
|
988
|
+
headAndShouldersContext: asRecord(signal.additionalIndicators).headAndShouldersContext
|
|
989
|
+
}
|
|
990
|
+
}),
|
|
991
|
+
buildHumanPromptAddon: ({ payload }) => {
|
|
992
|
+
const context = getContext(payload);
|
|
993
|
+
return `
|
|
994
|
+
Additional HeadAndShoulders context:
|
|
995
|
+
- patternKind=${context.patternKind ?? "n/a"}
|
|
996
|
+
- signalDirection=${context.signalDirection ?? "n/a"}
|
|
997
|
+
- entryMode=${context.entryMode ?? "n/a"}
|
|
998
|
+
- entryStage=${context.entryStage ?? "n/a"}
|
|
999
|
+
- neckline=${String(context.neckline ?? "n/a")}
|
|
1000
|
+
- necklineSlopeRatio=${String(context.necklineSlopeRatio ?? "n/a")}
|
|
1001
|
+
- headHeightPct=${String(context.headHeightPct ?? "n/a")}
|
|
1002
|
+
- headHeightAtr=${String(context.headHeightAtr ?? "n/a")}
|
|
1003
|
+
- shoulderDifferencePct=${String(context.shoulderDifferencePct ?? "n/a")}
|
|
1004
|
+
- leftHeadProminenceRatio=${String(context.leftHeadProminenceRatio ?? "n/a")}
|
|
1005
|
+
- rightHeadProminenceRatio=${String(context.rightHeadProminenceRatio ?? "n/a")}
|
|
1006
|
+
- patternSymmetryRatio=${String(context.patternSymmetryRatio ?? "n/a")}
|
|
1007
|
+
- priorMoveAtr=${String(context.priorMoveAtr ?? "n/a")}
|
|
1008
|
+
- breakoutDistanceAtr=${String(context.breakoutDistanceAtr ?? "n/a")}
|
|
1009
|
+
- breakoutDistanceHeightRatio=${String(context.breakoutDistanceHeightRatio ?? "n/a")}
|
|
1010
|
+
- breakoutDelayBars=${String(context.breakoutDelayBars ?? "n/a")}
|
|
1011
|
+
- breakoutCrossedOnSignalBar=${String(context.breakoutCrossedOnSignalBar ?? "n/a")}
|
|
1012
|
+
- confirmationBodyAtr=${String(context.confirmationBodyAtr ?? "n/a")}
|
|
1013
|
+
- confirmationCloseLocation=${String(context.confirmationCloseLocation ?? "n/a")}
|
|
1014
|
+
- confirmationVolumeRel=${String(context.confirmationVolumeRel ?? "n/a")}
|
|
1015
|
+
|
|
1016
|
+
Interpretation rules for HeadAndShoulders:
|
|
1017
|
+
- SHORT is a classic head-and-shoulders neckline breakdown; LONG is the mirrored inverse pattern.
|
|
1018
|
+
- Prefer balanced shoulders, a clearly prominent head, a moderate neckline slope, and a compact breakout.
|
|
1019
|
+
- Treat strongly asymmetric patterns, shallow heads, or overextended breakouts as lower quality.
|
|
1020
|
+
`.trim();
|
|
1021
|
+
},
|
|
1022
|
+
mapEntryRuntimeFromConfig: (config2) => (0, import_strategies.mapAiRuntimeFromConfig)(
|
|
1023
|
+
config2
|
|
1024
|
+
)
|
|
1025
|
+
};
|
|
1026
|
+
var headAndShouldersAiAdapter = (0, import_ai_gate.withStrategyLocalAiGate)(
|
|
1027
|
+
headAndShouldersBaseAiAdapter,
|
|
1028
|
+
{
|
|
1029
|
+
id: "head_and_shoulders_short_wick_breadth_2026_08_12",
|
|
1030
|
+
approves: ({ signal, payload }) => {
|
|
1031
|
+
const upperWickPct = (0, import_ai_gate.getAiPayloadNumber)(
|
|
1032
|
+
payload,
|
|
1033
|
+
"additionalIndicators.baseContext.structure.candleQuality.upperWickPct"
|
|
1034
|
+
);
|
|
1035
|
+
const altBasketReturn24h = (0, import_ai_gate.getAiPayloadNumber)(
|
|
1036
|
+
payload,
|
|
1037
|
+
"additionalIndicators.baseContext.relative.btcAltRegime.altBasketReturn24h"
|
|
1038
|
+
);
|
|
1039
|
+
return signal.direction === "SHORT" && upperWickPct != null && upperWickPct <= 0.3 && altBasketReturn24h != null && altBasketReturn24h >= -5e-3;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
// src/HeadAndShoulders/manifest.ts
|
|
1045
|
+
var headAndShouldersManifest = {
|
|
1046
|
+
name: "HeadAndShoulders",
|
|
1047
|
+
aiAdapter: headAndShouldersAiAdapter
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// src/HeadAndShoulders/strategy.ts
|
|
1051
|
+
var HeadAndShouldersStrategyDefinition = {
|
|
1052
|
+
defaults: config,
|
|
1053
|
+
createCore: createHeadAndShouldersCore,
|
|
1054
|
+
manifest: headAndShouldersManifest
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
// src/index.ts
|
|
1058
|
+
var strategyEntries = [
|
|
1059
|
+
HeadAndShouldersStrategyDefinition
|
|
1060
|
+
];
|
|
1061
|
+
var defaultConfigs = {
|
|
1062
|
+
HeadAndShoulders: config
|
|
1063
|
+
};
|
|
1064
|
+
var getBuiltInStrategyDefaultConfig = (strategyName) => defaultConfigs[strategyName];
|
|
1065
|
+
var index_default = (0, import_config4.defineStrategyPlugin)({ strategyEntries });
|
|
1066
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1067
|
+
0 && (module.exports = {
|
|
1068
|
+
HeadAndShouldersStrategyDefinition,
|
|
1069
|
+
getBuiltInStrategyDefaultConfig,
|
|
1070
|
+
headAndShouldersAiAdapter,
|
|
1071
|
+
headAndShouldersDefaultConfig,
|
|
1072
|
+
headAndShouldersManifest,
|
|
1073
|
+
strategyEntries
|
|
1074
|
+
});
|