|
| 1 | +const test = require('node:test'); |
| 2 | +const assert = require('node:assert/strict'); |
| 3 | +const { Job, Status } = require('../lib/job'); |
| 4 | + |
| 5 | +// A FILLED close at the deepest filled entry index used to end the cycle on the |
| 6 | +// index alone. But a close sized for ONE RUNG fills at that index too — that is |
| 7 | +// what a hybrid micro is, and once its 'role' marker is gone the classic machine |
| 8 | +// cannot tell it from the whole-position close. |
| 9 | +// |
| 10 | +// How the marker goes missing, seen live: the arm is raised past the carrying |
| 11 | +// rung, so it falls out of the scalp zone and the engine moves to pull its resting |
| 12 | +// micro. The price fills that micro first; the swap then places a classic close, |
| 13 | +// which deletes the marker on its way out and is itself rejected — the base it was |
| 14 | +// sized for has just been sold. The slot is left FILLED, rung-sized and roleless, |
| 15 | +// and the next tick reads it as the whole-position close: DONE, cancelOpenOrders |
| 16 | +// takes the tail with it, and the rest of the ladder sits on the balance with |
| 17 | +// nothing on the book. |
| 18 | +// |
| 19 | +// These pin the fix: the books decide, not the index. |
| 20 | + |
| 21 | +const mk = (side, status, over = {}) => ({ |
| 22 | + status, |
| 23 | + symbol: 'BNBUSDT', |
| 24 | + side, |
| 25 | + type: 'LIMIT', |
| 26 | + quantity: '0.023', |
| 27 | + price: '631.63', |
| 28 | + timeInForce: 'GTC', |
| 29 | + orderId: 1, |
| 30 | + ...over, |
| 31 | +}); |
| 32 | + |
| 33 | +// The seven filled BUY rungs of the incident, verbatim. |
| 34 | +const FILLS = [ |
| 35 | + ['0.023', '631.63', 0.023, 14.51852], |
| 36 | + ['0.029', '630.68', 0.029, 18.28972], |
| 37 | + ['0.036', '628.79', 0.036, 22.63644], |
| 38 | + ['0.046', '625.96', 0.046, 28.79416], |
| 39 | + ['0.058', '621.26', 0.058, 36.03308], |
| 40 | + ['0.075', '613.81', 0.075, 46.03575], |
| 41 | + ['0.096', '601.84', 0.096, 57.77664], |
| 42 | +]; |
| 43 | + |
| 44 | +function liveObj({ closeQty = 0.096, closeQuote = 58.2096 } = {}) { |
| 45 | + return { |
| 46 | + status: Status.STARTED, |
| 47 | + pair: 'BNBUSDT', |
| 48 | + param: { |
| 49 | + 'field-profit': '0.7', |
| 50 | + 'field-commission': '0.25', |
| 51 | + 'field-stepSize': '3', |
| 52 | + 'field-tickSize': '2', |
| 53 | + 'field-gridArm': '8', |
| 54 | + }, |
| 55 | + BUY: FILLS.map(([quantity, price, executedQty, cummulativeQuoteQty]) => |
| 56 | + mk('BUY', 'FILLED', { quantity, price, executedQty, cummulativeQuoteQty }) |
| 57 | + ), |
| 58 | + // Only the close on the deepest rung ever filled; the tail above it was pulled. |
| 59 | + SELL: [ |
| 60 | + ...Array.from({ length: 5 }, () => |
| 61 | + mk('SELL', 'CANCELED', { executedQty: 0, cummulativeQuoteQty: 0 }) |
| 62 | + ), |
| 63 | + mk('SELL', 'CANCELED', { |
| 64 | + quantity: '0.267', |
| 65 | + price: '614.78', |
| 66 | + role: 'tail', |
| 67 | + executedQty: 0, |
| 68 | + cummulativeQuoteQty: 0, |
| 69 | + }), |
| 70 | + // the ex-micro: rung-sized, role already deleted |
| 71 | + mk('SELL', 'FILLED', { |
| 72 | + quantity: String(closeQty), |
| 73 | + price: '606.35', |
| 74 | + executedQty: closeQty, |
| 75 | + cummulativeQuoteQty: closeQuote, |
| 76 | + }), |
| 77 | + ], |
| 78 | + gridRealized: 3.705449999999999, |
| 79 | + }; |
| 80 | +} |
| 81 | + |
| 82 | +test('long: a rung-sized close does not end the cycle — the leftover is re-closed', () => { |
| 83 | + const job = new Job(false); |
| 84 | + const obj = liveObj(); |
| 85 | + |
| 86 | + const res = job.long(obj, 6, obj.BUY[6]); |
| 87 | + |
| 88 | + assert.notEqual(res.status, Status.DONE, 'cycle must not end while 0.267 is held'); |
| 89 | + assert.equal(res.method, 'newOrder'); |
| 90 | + assert.equal(res.side, 'SELL'); |
| 91 | + assert.equal(res.id, 6); |
| 92 | + // 0.363 bought − 0.096 already sold, priced off the remaining fills with the |
| 93 | + // 3.705 bank folded in — the same numbers the UI badge shows. |
| 94 | + assert.equal(res.data.quantity, '0.267'); |
| 95 | + assert.equal(res.data.price, '613.15'); |
| 96 | +}); |
| 97 | + |
| 98 | +test('long: the whole-position close still ends the cycle', () => { |
| 99 | + const job = new Job(false); |
| 100 | + // this close sold everything the ladder held |
| 101 | + const obj = liveObj({ closeQty: 0.363, closeQuote: 224.9 }); |
| 102 | + |
| 103 | + const res = job.long(obj, 6, obj.BUY[6]); |
| 104 | + |
| 105 | + assert.equal(res.status, Status.DONE); |
| 106 | + assert.equal(res.method, 'cancelOpenOrders'); |
| 107 | + assert.equal(res.leftover, undefined); |
| 108 | +}); |
| 109 | + |
| 110 | +test('long: a leftover under the exchange minimum ends the cycle as dust', () => { |
| 111 | + const job = new Job(false); |
| 112 | + job.minNotional = 500; // 0.267 × 613.15 ≈ 164 → below |
| 113 | + const obj = liveObj(); |
| 114 | + |
| 115 | + const res = job.long(obj, 6, obj.BUY[6]); |
| 116 | + |
| 117 | + assert.equal(res.status, Status.DONE); |
| 118 | + assert.equal(res.method, 'cancelOpenOrders'); |
| 119 | + assert.equal(res.leftover.quantity, '0.267'); |
| 120 | + assert.equal(res.leftover.symbol, 'BNBUSDT'); |
| 121 | +}); |
| 122 | + |
| 123 | +test('long: an orphan below the filled close still yields to the lower index', () => { |
| 124 | + const job = new Job(false); |
| 125 | + const obj = liveObj(); |
| 126 | + |
| 127 | + // a filled buy deeper than the close → the old guard, untouched |
| 128 | + const res = job.long(obj, 5, obj.BUY[5]); |
| 129 | + |
| 130 | + assert.equal(res.status, 'pass'); |
| 131 | + assert.equal(res.method, false); |
| 132 | +}); |
| 133 | + |
| 134 | +test('long: a config without fill data ends the cycle as it always did', () => { |
| 135 | + const job = new Job(false); |
| 136 | + const obj = liveObj(); |
| 137 | + for (const b of obj.BUY) delete b.executedQty; |
| 138 | + |
| 139 | + const res = job.long(obj, 6, obj.BUY[6]); |
| 140 | + |
| 141 | + assert.equal(res.status, Status.DONE); |
| 142 | + assert.equal(res.method, 'cancelOpenOrders'); |
| 143 | +}); |
| 144 | + |
| 145 | +test('short: mirrored — a rung-sized close does not end the cycle', () => { |
| 146 | + const job = new Job(false); |
| 147 | + const obj = liveObj(); |
| 148 | + // mirror the ladder: the position is built with SELL, closed with BUY |
| 149 | + const mirrored = { |
| 150 | + ...obj, |
| 151 | + SELL: obj.BUY.map((o) => ({ ...o, side: 'SELL' })), |
| 152 | + BUY: obj.SELL.map((o) => ({ ...o, side: 'BUY' })), |
| 153 | + }; |
| 154 | + |
| 155 | + const res = job.short(mirrored, 6, mirrored.SELL[6]); |
| 156 | + |
| 157 | + assert.notEqual(res.status, Status.DONE, 'cycle must not end while 0.267 is held'); |
| 158 | + assert.equal(res.method, 'newOrder'); |
| 159 | + assert.equal(res.side, 'BUY'); |
| 160 | + assert.equal(res.data.quantity, '0.267'); |
| 161 | +}); |
0 commit comments