@tradejs/strategies 1.0.5 → 1.0.6

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.
@@ -0,0 +1,736 @@
1
+ import {
2
+ buildVolumeDivergenceSetupFeatures,
3
+ getVolumeDivergenceEntryThresholds,
4
+ volumeDivergenceManifest
5
+ } from "./chunk-XMRB45ZO.mjs";
6
+ import "./chunk-HEBXNMVQ.mjs";
7
+
8
+ // src/VolumeDivergence/strategy.ts
9
+ import { createStrategyRuntime } from "@tradejs/node/strategies";
10
+
11
+ // src/VolumeDivergence/config.ts
12
+ var config = {
13
+ ENV: "BACKTEST",
14
+ INTERVAL: "15",
15
+ MAKE_ORDERS: true,
16
+ CLOSE_OPPOSITE_POSITIONS: false,
17
+ BACKTEST_PRICE_MODE: "mid",
18
+ AI_ENABLED: false,
19
+ ML_ENABLED: false,
20
+ ML_THRESHOLD: 0.1,
21
+ MIN_AI_QUALITY: 3,
22
+ FEE_PERCENT: 5e-3,
23
+ MAX_LOSS_VALUE: 10,
24
+ MA_FAST: 14,
25
+ MA_MEDIUM: 49,
26
+ MA_SLOW: 50,
27
+ OBV_SMA: 10,
28
+ ATR: 14,
29
+ ATR_PCT_SHORT: 7,
30
+ ATR_PCT_LONG: 30,
31
+ BB: 20,
32
+ BB_STD: 2,
33
+ MACD_FAST: 12,
34
+ MACD_SLOW: 26,
35
+ MACD_SIGNAL: 9,
36
+ LEVEL_LOOKBACK: 20,
37
+ LEVEL_DELAY: 2,
38
+ NORMALIZATION_LENGTH: 100,
39
+ PIVOT_LOOKBACK_LEFT: 8,
40
+ PIVOT_LOOKBACK_RIGHT: 3,
41
+ MIN_BARS_BETWEEN_PIVOTS: 4,
42
+ MAX_BARS_BETWEEN_PIVOTS: 36,
43
+ ALLOW_STRUCTURE_ADVANCE_ENTRY: false,
44
+ MIN_DIVERGENCE_AMPLITUDE_ATR_RATIO: 0.35,
45
+ MIN_RECLAIM_PCT: 105,
46
+ MIN_CONFIRMATION_CANDLE_QUALITY: 0.58,
47
+ BULLISH: {
48
+ enable: true,
49
+ direction: "LONG",
50
+ TP: 4,
51
+ SL: 1.3,
52
+ minRiskRatio: 2
53
+ },
54
+ BEARISH: {
55
+ enable: true,
56
+ direction: "SHORT",
57
+ TP: 4,
58
+ SL: 1.3,
59
+ minRiskRatio: 2
60
+ }
61
+ };
62
+
63
+ // src/VolumeDivergence/core.ts
64
+ import { round } from "@tradejs/core/math";
65
+
66
+ // src/VolumeDivergence/figures.ts
67
+ var buildVolumeDivergenceFigures = ({
68
+ kind,
69
+ previousPivotIndex,
70
+ currentPivotIndex,
71
+ previousPivotLow,
72
+ previousPivotHigh,
73
+ currentPivotLow,
74
+ currentPivotHigh,
75
+ fullData
76
+ }) => ({
77
+ lines: [
78
+ {
79
+ id: `volume-divergence-price-${kind}`,
80
+ kind: `volume_divergence_${kind}_price`,
81
+ points: [
82
+ {
83
+ timestamp: fullData[previousPivotIndex]?.timestamp ?? 0,
84
+ value: kind === "bullish" ? previousPivotLow : previousPivotHigh
85
+ },
86
+ {
87
+ timestamp: fullData[currentPivotIndex]?.timestamp ?? 0,
88
+ value: kind === "bullish" ? currentPivotLow : currentPivotHigh
89
+ }
90
+ ],
91
+ color: kind === "bullish" ? "#22c55e" : "#ef4444",
92
+ width: 2,
93
+ style: "dashed"
94
+ }
95
+ ],
96
+ points: [
97
+ {
98
+ id: `volume-divergence-pivots-${kind}`,
99
+ kind: `volume_divergence_${kind}_pivots`,
100
+ points: [
101
+ {
102
+ timestamp: fullData[previousPivotIndex]?.timestamp ?? 0,
103
+ value: kind === "bullish" ? previousPivotLow : previousPivotHigh
104
+ },
105
+ {
106
+ timestamp: fullData[currentPivotIndex]?.timestamp ?? 0,
107
+ value: kind === "bullish" ? currentPivotLow : currentPivotHigh
108
+ }
109
+ ],
110
+ color: kind === "bullish" ? "#22c55e" : "#ef4444",
111
+ radius: 4
112
+ }
113
+ ]
114
+ });
115
+
116
+ // src/VolumeDivergence/core.ts
117
+ var BREAK_EVEN_TRIGGER_RISK_MULTIPLIER = 0.5;
118
+ var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
119
+ var clamp = (value, min, max) => Math.min(max, Math.max(min, value));
120
+ var isOpenPosition = (position) => Boolean(
121
+ 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")
122
+ );
123
+ var getFavorableMovePct = ({
124
+ direction,
125
+ entryPrice,
126
+ currentPrice
127
+ }) => {
128
+ if (!Number.isFinite(entryPrice) || !Number.isFinite(currentPrice) || entryPrice <= 0) {
129
+ return null;
130
+ }
131
+ return direction === "LONG" ? (currentPrice - entryPrice) / entryPrice * 100 : (entryPrice - currentPrice) / entryPrice * 100;
132
+ };
133
+ var getPositionStopLossPrice = (position) => {
134
+ if (!position || typeof position !== "object") {
135
+ return null;
136
+ }
137
+ const slPrice = Number(
138
+ position.slPrice ?? Number.NaN
139
+ );
140
+ if (Number.isFinite(slPrice)) {
141
+ return slPrice;
142
+ }
143
+ const signalStopLossPrice = Number(
144
+ position.signal?.prices?.stopLossPrice ?? Number.NaN
145
+ );
146
+ return Number.isFinite(signalStopLossPrice) ? signalStopLossPrice : null;
147
+ };
148
+ var getPositionRiskPct = ({
149
+ direction,
150
+ entryPrice,
151
+ stopLossPrice
152
+ }) => {
153
+ if (stopLossPrice == null || !Number.isFinite(entryPrice) || !Number.isFinite(stopLossPrice) || entryPrice <= 0) {
154
+ return null;
155
+ }
156
+ return direction === "LONG" ? (entryPrice - stopLossPrice) / entryPrice * 100 : (stopLossPrice - entryPrice) / entryPrice * 100;
157
+ };
158
+ var isBreakEvenStopAlreadyApplied = ({
159
+ direction,
160
+ entryPrice,
161
+ stopLossPrice
162
+ }) => {
163
+ if (stopLossPrice == null || !Number.isFinite(entryPrice) || !Number.isFinite(stopLossPrice)) {
164
+ return false;
165
+ }
166
+ return direction === "LONG" ? stopLossPrice >= entryPrice : stopLossPrice <= entryPrice;
167
+ };
168
+ var compactQueue = (queue) => {
169
+ if (queue.start <= 1024 || queue.start * 2 <= queue.indices.length) {
170
+ return;
171
+ }
172
+ queue.indices.splice(0, queue.start);
173
+ queue.start = 0;
174
+ };
175
+ var rebaseQueue = (queue, offset) => {
176
+ queue.indices = queue.indices.slice(queue.start).map((index) => index - offset).filter((index) => index >= 0);
177
+ queue.start = 0;
178
+ };
179
+ var rebaseConfirmedPivots = (state, offset) => {
180
+ state.indices = state.indices.map((index) => index - offset).filter((index) => index >= 0);
181
+ state.nextConfirmationIndex = Math.max(
182
+ 0,
183
+ state.nextConfirmationIndex - offset
184
+ );
185
+ };
186
+ var appendNormalizedVolumes = ({
187
+ candles,
188
+ length,
189
+ normalizedVolumes,
190
+ queue
191
+ }) => {
192
+ while (normalizedVolumes.length < candles.length) {
193
+ const i = normalizedVolumes.length;
194
+ const windowStart = Math.max(0, i - length + 1);
195
+ const volume = Number(candles[i]?.volume) || 0;
196
+ while (queue.start < queue.indices.length && queue.indices[queue.start] < windowStart) {
197
+ queue.start += 1;
198
+ }
199
+ while (queue.indices.length > queue.start) {
200
+ const lastIndex = queue.indices[queue.indices.length - 1];
201
+ const lastVolume = Number(candles[lastIndex]?.volume) || 0;
202
+ if (lastVolume > volume) {
203
+ break;
204
+ }
205
+ queue.indices.pop();
206
+ }
207
+ queue.indices.push(i);
208
+ compactQueue(queue);
209
+ const highestIndex = queue.indices[queue.start];
210
+ const highest = Number(candles[highestIndex]?.volume) || 0;
211
+ normalizedVolumes.push(highest > 0 ? volume / highest * 100 : 0);
212
+ }
213
+ };
214
+ var isPivotHigh = ({
215
+ values,
216
+ index,
217
+ left,
218
+ right
219
+ }) => {
220
+ const pivotValue = values[index];
221
+ if (!isFiniteNumber(pivotValue)) {
222
+ return false;
223
+ }
224
+ if (index - left < 0 || index + right >= values.length) {
225
+ return false;
226
+ }
227
+ for (let i = index - left; i <= index + right; i += 1) {
228
+ if (i === index) continue;
229
+ if (!isFiniteNumber(values[i]) || values[i] >= pivotValue) {
230
+ return false;
231
+ }
232
+ }
233
+ return true;
234
+ };
235
+ var candleDeltaProxy = (candle) => {
236
+ const volume = Number(candle.volume) || 0;
237
+ const range = Math.max(Math.abs(candle.high - candle.low), 1e-9);
238
+ const bodyBias = (candle.close - candle.open) / range;
239
+ return volume * clamp(bodyBias, -1, 1);
240
+ };
241
+ var appendConfirmedPivotIndices = ({
242
+ candles,
243
+ normalizedVolumes,
244
+ lookbackLeft,
245
+ lookbackRight,
246
+ state
247
+ }) => {
248
+ const maxConfirmationIndex = candles.length - 1;
249
+ while (state.nextConfirmationIndex <= maxConfirmationIndex) {
250
+ const candidatePivotIndex = state.nextConfirmationIndex - lookbackRight;
251
+ if (candidatePivotIndex >= 0 && isPivotHigh({
252
+ values: normalizedVolumes,
253
+ index: candidatePivotIndex,
254
+ left: lookbackLeft,
255
+ right: lookbackRight
256
+ })) {
257
+ state.indices.push(candidatePivotIndex);
258
+ }
259
+ state.nextConfirmationIndex += 1;
260
+ }
261
+ };
262
+ var findLatestDivergence = ({
263
+ candles,
264
+ normalizedVolumes,
265
+ confirmedPivots,
266
+ lookbackLeft,
267
+ lookbackRight,
268
+ rangeLower,
269
+ rangeUpper
270
+ }) => {
271
+ const currentConfirmationIndex = candles.length - 1;
272
+ const currentPivotIndex = currentConfirmationIndex - lookbackRight;
273
+ if (currentPivotIndex <= 0) {
274
+ return null;
275
+ }
276
+ const lastConfirmedPivotIndex = confirmedPivots.length > 0 ? confirmedPivots[confirmedPivots.length - 1] : void 0;
277
+ if (lastConfirmedPivotIndex !== currentPivotIndex) {
278
+ return null;
279
+ }
280
+ const previousPivotIndex = confirmedPivots.length > 1 ? confirmedPivots[confirmedPivots.length - 2] : void 0;
281
+ if (previousPivotIndex == null || previousPivotIndex < lookbackLeft) {
282
+ return null;
283
+ }
284
+ const previousConfirmationIndex = previousPivotIndex + lookbackRight;
285
+ const barsBetweenPivotConfirmations = currentConfirmationIndex - previousConfirmationIndex - 1;
286
+ if (barsBetweenPivotConfirmations < rangeLower || barsBetweenPivotConfirmations > rangeUpper) {
287
+ return null;
288
+ }
289
+ const currentPivotVolumeNorm = normalizedVolumes[currentPivotIndex];
290
+ const previousPivotVolumeNorm = normalizedVolumes[previousPivotIndex];
291
+ const currentPivotLow = Number(candles[currentPivotIndex]?.low);
292
+ const previousPivotLow = Number(candles[previousPivotIndex]?.low);
293
+ const currentPivotHigh = Number(candles[currentPivotIndex]?.high);
294
+ const previousPivotHigh = Number(candles[previousPivotIndex]?.high);
295
+ const currentPivotCandle = candles[currentPivotIndex];
296
+ if (!isFiniteNumber(currentPivotVolumeNorm) || !isFiniteNumber(previousPivotVolumeNorm) || !isFiniteNumber(currentPivotLow) || !isFiniteNumber(previousPivotLow) || !isFiniteNumber(currentPivotHigh) || !isFiniteNumber(previousPivotHigh)) {
297
+ return null;
298
+ }
299
+ const volHigherLow = currentPivotVolumeNorm > previousPivotVolumeNorm;
300
+ const volLowerHigh = currentPivotVolumeNorm < previousPivotVolumeNorm;
301
+ const priceLowerLow = currentPivotLow < previousPivotLow;
302
+ const priceHigherHigh = currentPivotHigh > previousPivotHigh;
303
+ if (priceLowerLow && volHigherLow) {
304
+ return {
305
+ currentPivotIndex,
306
+ previousPivotIndex,
307
+ currentPivotVolumeNorm,
308
+ previousPivotVolumeNorm,
309
+ currentPivotLow,
310
+ previousPivotLow,
311
+ currentPivotHigh,
312
+ previousPivotHigh,
313
+ currentPivotVolume: Number(currentPivotCandle.volume) || 0,
314
+ currentPivotDelta: candleDeltaProxy(currentPivotCandle),
315
+ barsBetweenPivotConfirmations,
316
+ kind: "bullish"
317
+ };
318
+ }
319
+ if (priceHigherHigh && volLowerHigh) {
320
+ return {
321
+ currentPivotIndex,
322
+ previousPivotIndex,
323
+ currentPivotVolumeNorm,
324
+ previousPivotVolumeNorm,
325
+ currentPivotLow,
326
+ previousPivotLow,
327
+ currentPivotHigh,
328
+ previousPivotHigh,
329
+ currentPivotVolume: Number(currentPivotCandle.volume) || 0,
330
+ currentPivotDelta: candleDeltaProxy(currentPivotCandle),
331
+ barsBetweenPivotConfirmations,
332
+ kind: "bearish"
333
+ };
334
+ }
335
+ return null;
336
+ };
337
+ var getRequiredHistorySize = ({
338
+ normalizationLength,
339
+ lookbackLeft,
340
+ lookbackRight,
341
+ maxBarsBetweenPivots
342
+ }) => normalizationLength + maxBarsBetweenPivots + lookbackLeft + lookbackRight * 2 + 8;
343
+ var buildPendingDivergenceCandidate = ({
344
+ divergence,
345
+ candleWindow,
346
+ direction,
347
+ pivotLookbackLeft,
348
+ pivotLookbackRight,
349
+ detectedAtTimestamp
350
+ }) => ({
351
+ kind: divergence.kind,
352
+ direction,
353
+ currentPivotVolumeNorm: divergence.currentPivotVolumeNorm,
354
+ previousPivotVolumeNorm: divergence.previousPivotVolumeNorm,
355
+ currentPivotLow: divergence.currentPivotLow,
356
+ previousPivotLow: divergence.previousPivotLow,
357
+ currentPivotHigh: divergence.currentPivotHigh,
358
+ previousPivotHigh: divergence.previousPivotHigh,
359
+ currentPivotVolume: divergence.currentPivotVolume,
360
+ currentPivotDelta: divergence.currentPivotDelta,
361
+ barsBetweenPivotConfirmations: divergence.barsBetweenPivotConfirmations,
362
+ currentPivotTimestamp: Number(candleWindow[divergence.currentPivotIndex]?.timestamp) || null,
363
+ previousPivotTimestamp: Number(candleWindow[divergence.previousPivotIndex]?.timestamp) || null,
364
+ pivotLookbackLeft,
365
+ pivotLookbackRight,
366
+ detectedAtTimestamp,
367
+ lastObservedTimestamp: detectedAtTimestamp,
368
+ barsSinceDetection: 0
369
+ });
370
+ var updatePendingCandidateProgress = (candidate, timestamp) => {
371
+ if (candidate.lastObservedTimestamp === timestamp) {
372
+ return;
373
+ }
374
+ candidate.barsSinceDetection += 1;
375
+ candidate.lastObservedTimestamp = timestamp;
376
+ };
377
+ var resolvePendingEntryTiming = ({
378
+ candidate,
379
+ currentPrice
380
+ }) => {
381
+ if (candidate.direction === "LONG") {
382
+ if (currentPrice >= candidate.currentPivotHigh) {
383
+ return "confirmation_ready";
384
+ }
385
+ if (currentPrice >= candidate.previousPivotLow) {
386
+ return "structure_advance";
387
+ }
388
+ return null;
389
+ }
390
+ if (currentPrice <= candidate.currentPivotLow) {
391
+ return "confirmation_ready";
392
+ }
393
+ if (currentPrice <= candidate.previousPivotHigh) {
394
+ return "structure_advance";
395
+ }
396
+ return null;
397
+ };
398
+ var findCandleIndexByTimestamp = (candles, timestamp, fallbackIndex) => {
399
+ if (timestamp == null) {
400
+ return fallbackIndex;
401
+ }
402
+ const index = candles.findIndex(
403
+ (candle) => Number(candle.timestamp) === timestamp
404
+ );
405
+ return index >= 0 ? index : fallbackIndex;
406
+ };
407
+ var getModeConfigByKind = ({
408
+ kind,
409
+ bullish,
410
+ bearish
411
+ }) => kind === "bullish" ? bullish : bearish;
412
+ var buildEntryPayloadFromPendingCandidate = ({
413
+ candidate,
414
+ candleWindow,
415
+ entryTiming,
416
+ setupFeatures,
417
+ entryThresholds
418
+ }) => {
419
+ const previousPivotIndex = findCandleIndexByTimestamp(
420
+ candleWindow,
421
+ candidate.previousPivotTimestamp,
422
+ 0
423
+ );
424
+ const currentPivotIndex = findCandleIndexByTimestamp(
425
+ candleWindow,
426
+ candidate.currentPivotTimestamp,
427
+ candleWindow.length - 1
428
+ );
429
+ return {
430
+ figures: buildVolumeDivergenceFigures({
431
+ kind: candidate.kind,
432
+ previousPivotIndex,
433
+ currentPivotIndex,
434
+ previousPivotLow: candidate.previousPivotLow,
435
+ previousPivotHigh: candidate.previousPivotHigh,
436
+ currentPivotLow: candidate.currentPivotLow,
437
+ currentPivotHigh: candidate.currentPivotHigh,
438
+ fullData: candleWindow
439
+ }),
440
+ additionalIndicators: {
441
+ divergenceKind: candidate.kind,
442
+ normalizedVolumeAtPivot: candidate.currentPivotVolumeNorm,
443
+ previousNormalizedVolumeAtPivot: candidate.previousPivotVolumeNorm,
444
+ volumeAtPivot: candidate.currentPivotVolume,
445
+ deltaAtPivot: candidate.currentPivotDelta,
446
+ barsBetweenPivotConfirmations: candidate.barsBetweenPivotConfirmations,
447
+ divergence: {
448
+ kind: candidate.kind,
449
+ pivotLookbackLeft: candidate.pivotLookbackLeft,
450
+ pivotLookbackRight: candidate.pivotLookbackRight,
451
+ currentPivot: {
452
+ index: currentPivotIndex,
453
+ timestamp: candidate.currentPivotTimestamp,
454
+ priceLow: candidate.currentPivotLow,
455
+ priceHigh: candidate.currentPivotHigh,
456
+ volumeNorm: candidate.currentPivotVolumeNorm
457
+ },
458
+ previousPivot: {
459
+ index: previousPivotIndex,
460
+ timestamp: candidate.previousPivotTimestamp,
461
+ priceLow: candidate.previousPivotLow,
462
+ priceHigh: candidate.previousPivotHigh,
463
+ volumeNorm: candidate.previousPivotVolumeNorm
464
+ },
465
+ barsBetweenPivotConfirmations: candidate.barsBetweenPivotConfirmations
466
+ },
467
+ volumeDivergenceSignalTiming: {
468
+ entryTiming,
469
+ barsSinceDetection: candidate.barsSinceDetection,
470
+ detectedAtTimestamp: candidate.detectedAtTimestamp
471
+ },
472
+ volumeDivergenceSetup: setupFeatures,
473
+ volumeDivergenceThresholds: entryThresholds
474
+ }
475
+ };
476
+ };
477
+ var createVolumeDivergenceCore = async ({ config: config2, strategyApi, indicatorsState, data: initialData }) => {
478
+ const {
479
+ NORMALIZATION_LENGTH,
480
+ PIVOT_LOOKBACK_LEFT,
481
+ PIVOT_LOOKBACK_RIGHT,
482
+ MAX_BARS_BETWEEN_PIVOTS,
483
+ MIN_BARS_BETWEEN_PIVOTS,
484
+ ALLOW_STRUCTURE_ADVANCE_ENTRY,
485
+ MIN_DIVERGENCE_AMPLITUDE_ATR_RATIO,
486
+ MIN_RECLAIM_PCT,
487
+ MIN_CONFIRMATION_CANDLE_QUALITY,
488
+ FEE_PERCENT,
489
+ MAX_LOSS_VALUE,
490
+ BULLISH,
491
+ BEARISH
492
+ } = config2;
493
+ const entryThresholds = getVolumeDivergenceEntryThresholds({
494
+ ALLOW_STRUCTURE_ADVANCE_ENTRY,
495
+ MIN_DIVERGENCE_AMPLITUDE_ATR_RATIO,
496
+ MIN_RECLAIM_PCT,
497
+ MIN_CONFIRMATION_CANDLE_QUALITY
498
+ });
499
+ const lastTradeController = strategyApi.createLastTradeController();
500
+ const maxHistorySize = getRequiredHistorySize({
501
+ normalizationLength: NORMALIZATION_LENGTH,
502
+ lookbackLeft: PIVOT_LOOKBACK_LEFT,
503
+ lookbackRight: PIVOT_LOOKBACK_RIGHT,
504
+ maxBarsBetweenPivots: MAX_BARS_BETWEEN_PIVOTS
505
+ });
506
+ const maxPendingConfirmationBars = Math.max(
507
+ 2,
508
+ Math.min(
509
+ MAX_BARS_BETWEEN_PIVOTS,
510
+ PIVOT_LOOKBACK_LEFT + PIVOT_LOOKBACK_RIGHT + 1
511
+ )
512
+ );
513
+ const candleWindow = Array.isArray(initialData) ? initialData.slice(-maxHistorySize) : [];
514
+ const normalizedVolumes = [];
515
+ const rollingMaxQueue = {
516
+ indices: [],
517
+ start: 0
518
+ };
519
+ const confirmedPivotState = {
520
+ indices: [],
521
+ nextConfirmationIndex: 0
522
+ };
523
+ let pendingCandidate = null;
524
+ const syncDerivedState = () => {
525
+ appendNormalizedVolumes({
526
+ candles: candleWindow,
527
+ length: NORMALIZATION_LENGTH,
528
+ normalizedVolumes,
529
+ queue: rollingMaxQueue
530
+ });
531
+ appendConfirmedPivotIndices({
532
+ candles: candleWindow,
533
+ normalizedVolumes,
534
+ lookbackLeft: PIVOT_LOOKBACK_LEFT,
535
+ lookbackRight: PIVOT_LOOKBACK_RIGHT,
536
+ state: confirmedPivotState
537
+ });
538
+ };
539
+ const appendWindowCandle = (candle) => {
540
+ const latestTimestamp = candleWindow.length > 0 ? Number(candleWindow[candleWindow.length - 1]?.timestamp) : null;
541
+ if (latestTimestamp === Number(candle.timestamp)) {
542
+ candleWindow[candleWindow.length - 1] = candle;
543
+ normalizedVolumes.length = 0;
544
+ rollingMaxQueue.indices = [];
545
+ rollingMaxQueue.start = 0;
546
+ confirmedPivotState.indices = [];
547
+ confirmedPivotState.nextConfirmationIndex = 0;
548
+ syncDerivedState();
549
+ return;
550
+ }
551
+ candleWindow.push(candle);
552
+ if (candleWindow.length > maxHistorySize) {
553
+ const overflow = candleWindow.length - maxHistorySize;
554
+ candleWindow.splice(0, overflow);
555
+ normalizedVolumes.splice(0, overflow);
556
+ rebaseQueue(rollingMaxQueue, overflow);
557
+ rebaseConfirmedPivots(confirmedPivotState, overflow);
558
+ }
559
+ syncDerivedState();
560
+ };
561
+ syncDerivedState();
562
+ return async (candle) => {
563
+ appendWindowCandle(candle);
564
+ indicatorsState.onBar();
565
+ const timestamp = Number(candle.timestamp);
566
+ const currentPosition = await strategyApi.getCurrentPosition();
567
+ if (isOpenPosition(currentPosition)) {
568
+ const { currentPrice: currentPrice2 } = await strategyApi.getMarketData();
569
+ const activeModeConfig = currentPosition.direction === "LONG" ? BULLISH : BEARISH;
570
+ const currentStopLossPrice = getPositionStopLossPrice(currentPosition);
571
+ const favorableMovePct = getFavorableMovePct({
572
+ direction: currentPosition.direction,
573
+ entryPrice: currentPosition.price,
574
+ currentPrice: currentPrice2
575
+ });
576
+ const currentPositionRiskPct = getPositionRiskPct({
577
+ direction: currentPosition.direction,
578
+ entryPrice: currentPosition.price,
579
+ stopLossPrice: currentStopLossPrice
580
+ });
581
+ if (!isBreakEvenStopAlreadyApplied({
582
+ direction: currentPosition.direction,
583
+ entryPrice: currentPosition.price,
584
+ stopLossPrice: currentStopLossPrice
585
+ }) && favorableMovePct != null && favorableMovePct >= (currentPositionRiskPct ?? activeModeConfig.SL) * BREAK_EVEN_TRIGGER_RISK_MULTIPLIER) {
586
+ return strategyApi.protect({
587
+ code: "VOLUME_DIVERGENCE_MOVE_STOP_TO_BREAK_EVEN",
588
+ protectPlan: {
589
+ direction: currentPosition.direction,
590
+ stopLossPrice: currentPosition.price
591
+ }
592
+ });
593
+ }
594
+ return strategyApi.skip("POSITION_EXISTS");
595
+ }
596
+ if (candleWindow.length < PIVOT_LOOKBACK_LEFT + PIVOT_LOOKBACK_RIGHT + 2) {
597
+ return strategyApi.skip("WAIT_DATA");
598
+ }
599
+ if (lastTradeController.isInCooldown(timestamp)) {
600
+ return strategyApi.skip("DEV_TRADE_COOLDOWN");
601
+ }
602
+ const divergence = findLatestDivergence({
603
+ candles: candleWindow,
604
+ normalizedVolumes,
605
+ confirmedPivots: confirmedPivotState.indices,
606
+ lookbackLeft: PIVOT_LOOKBACK_LEFT,
607
+ lookbackRight: PIVOT_LOOKBACK_RIGHT,
608
+ rangeLower: MIN_BARS_BETWEEN_PIVOTS,
609
+ rangeUpper: MAX_BARS_BETWEEN_PIVOTS
610
+ });
611
+ if (divergence) {
612
+ const modeConfig2 = getModeConfigByKind({
613
+ kind: divergence.kind,
614
+ bullish: BULLISH,
615
+ bearish: BEARISH
616
+ });
617
+ if (!modeConfig2.enable) {
618
+ return strategyApi.skip("STRATEGY_DISABLED");
619
+ }
620
+ const nextPendingCandidate = buildPendingDivergenceCandidate({
621
+ divergence,
622
+ candleWindow,
623
+ direction: modeConfig2.direction,
624
+ pivotLookbackLeft: PIVOT_LOOKBACK_LEFT,
625
+ pivotLookbackRight: PIVOT_LOOKBACK_RIGHT,
626
+ detectedAtTimestamp: timestamp
627
+ });
628
+ const detectionSetupFeatures = buildVolumeDivergenceSetupFeatures({
629
+ candles: candleWindow,
630
+ currentCandle: candle,
631
+ direction: modeConfig2.direction,
632
+ currentPrice: Number(candle.close),
633
+ currentPivotLow: nextPendingCandidate.currentPivotLow,
634
+ previousPivotLow: nextPendingCandidate.previousPivotLow,
635
+ currentPivotHigh: nextPendingCandidate.currentPivotHigh,
636
+ previousPivotHigh: nextPendingCandidate.previousPivotHigh,
637
+ atrPeriod: config2.ATR
638
+ });
639
+ if (detectionSetupFeatures.divergenceAmplitudeAtrRatio != null && detectionSetupFeatures.divergenceAmplitudeAtrRatio < entryThresholds.minDivergenceAmplitudeAtrRatio) {
640
+ return strategyApi.skip("WEAK_DIVERGENCE_AMPLITUDE_ATR");
641
+ }
642
+ pendingCandidate = nextPendingCandidate;
643
+ return strategyApi.skip("WAIT_REVERSAL_CONFIRMATION");
644
+ }
645
+ if (!pendingCandidate) {
646
+ return strategyApi.skip("NO_DIVERGENCE");
647
+ }
648
+ updatePendingCandidateProgress(pendingCandidate, timestamp);
649
+ if (pendingCandidate.barsSinceDetection > maxPendingConfirmationBars) {
650
+ pendingCandidate = null;
651
+ return strategyApi.skip("PENDING_DIVERGENCE_EXPIRED");
652
+ }
653
+ const { currentPrice } = await strategyApi.getMarketData();
654
+ const entryTiming = resolvePendingEntryTiming({
655
+ candidate: pendingCandidate,
656
+ currentPrice
657
+ });
658
+ if (!entryTiming) {
659
+ return strategyApi.skip("WAIT_REVERSAL_CONFIRMATION");
660
+ }
661
+ if (entryTiming === "structure_advance" && !entryThresholds.allowStructureAdvanceEntry) {
662
+ return strategyApi.skip("WAIT_CONFIRMATION_READY");
663
+ }
664
+ const modeConfig = getModeConfigByKind({
665
+ kind: pendingCandidate.kind,
666
+ bullish: BULLISH,
667
+ bearish: BEARISH
668
+ });
669
+ const setupFeatures = buildVolumeDivergenceSetupFeatures({
670
+ candles: candleWindow,
671
+ currentCandle: candle,
672
+ direction: modeConfig.direction,
673
+ currentPrice,
674
+ currentPivotLow: pendingCandidate.currentPivotLow,
675
+ previousPivotLow: pendingCandidate.previousPivotLow,
676
+ currentPivotHigh: pendingCandidate.currentPivotHigh,
677
+ previousPivotHigh: pendingCandidate.previousPivotHigh,
678
+ atrPeriod: config2.ATR
679
+ });
680
+ if (setupFeatures.reclaimPct != null && setupFeatures.reclaimPct < entryThresholds.minReclaimPct) {
681
+ return strategyApi.skip("WAIT_CONFIRMATION_RECLAIM");
682
+ }
683
+ if (setupFeatures.confirmationCandleQuality != null && setupFeatures.confirmationCandleQuality < entryThresholds.minConfirmationCandleQuality) {
684
+ return strategyApi.skip("WAIT_CONFIRMATION_CANDLE_QUALITY");
685
+ }
686
+ const { stopLossPrice, takeProfitPrice, riskRatio, qty } = strategyApi.getDirectionalTpSlPrices({
687
+ price: currentPrice,
688
+ direction: modeConfig.direction,
689
+ takeProfitDelta: modeConfig.TP,
690
+ stopLossDelta: modeConfig.SL,
691
+ unit: "percent",
692
+ maxLossValue: MAX_LOSS_VALUE,
693
+ feePercent: Number(FEE_PERCENT ?? 0)
694
+ });
695
+ if (!qty || !Number.isFinite(qty) || qty <= 0) {
696
+ return strategyApi.skip("INVALID_QTY");
697
+ }
698
+ if (riskRatio <= modeConfig.minRiskRatio) {
699
+ return strategyApi.skip(`RISK_RATIO:${round(riskRatio)}`);
700
+ }
701
+ const indicators = indicatorsState.snapshot();
702
+ const entryPayload = buildEntryPayloadFromPendingCandidate({
703
+ candidate: pendingCandidate,
704
+ candleWindow,
705
+ entryTiming,
706
+ setupFeatures,
707
+ entryThresholds
708
+ });
709
+ lastTradeController.markTrade(timestamp);
710
+ pendingCandidate = null;
711
+ return strategyApi.entry({
712
+ code: "VOLUME_DIVERGENCE_REVERSAL_SIGNAL",
713
+ direction: modeConfig.direction,
714
+ figures: entryPayload.figures,
715
+ indicators,
716
+ additionalIndicators: entryPayload.additionalIndicators,
717
+ orderPlan: {
718
+ qty,
719
+ stopLossPrice,
720
+ takeProfits: [{ rate: 1, price: takeProfitPrice }]
721
+ }
722
+ });
723
+ };
724
+ };
725
+
726
+ // src/VolumeDivergence/strategy.ts
727
+ var VolumeDivergenceStrategyCreator = createStrategyRuntime({
728
+ strategyName: "VolumeDivergence",
729
+ defaults: config,
730
+ createCore: createVolumeDivergenceCore,
731
+ manifest: volumeDivergenceManifest,
732
+ strategyDirectory: __dirname
733
+ });
734
+ export {
735
+ VolumeDivergenceStrategyCreator
736
+ };