Skip to content

Commit 2efa14c

Browse files
committed
Merge branch 'dev' into 'main'
v2.0.4 — Cycle-close leftover fix See merge request AndreyPopov/spot-trading-bot!46
2 parents 702d0c7 + 92c1e45 commit 2efa14c

9 files changed

Lines changed: 391 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## v2.0.4
4+
5+
### Fixed
6+
7+
- Cycle no longer ends when a close fills at the deepest held rung but the position isn't flat: the classic close now recomputes the leftover from the real fills instead of trusting a stale rung-sized order left on the slot by a pulled hybrid scalp.
8+
9+
### Changed
10+
11+
- Telegram: batched trade messages drop the closing separator line; Start/Pause/Series deleted messages get a color-coded square icon.
12+
313
## v2.0.3
414

515
### Fixed

src/lib/job.js

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,17 @@ class Job {
310310
if (obj['SELL'][i].manual) {
311311
return { status: 'pass', method: false, side: null, id: i, data: {} };
312312
}
313-
const reb = rebalancedClose(obj, i, 'long'); // null → precompute
313+
// No partial fills to net out → rebalancedClose bows out and the slot
314+
// plan takes over. That plan is only trustworthy while the slot still
315+
// holds what the calculator wrote: the scalp overwrites price/qty with
316+
// ITS OWN rung-sized numbers, and they outlive the micro. Seen live:
317+
// the hybrid was switched off, the micro was pulled, and the classic
318+
// close inherited 0.096 @ 606.35 while 0.363 was held — a quarter of the
319+
// position covered, for three days, until that close filled and ended
320+
// the cycle. So recompute from the real fills first (#fullClose does not
321+
// bail out on a clean book) and keep the slot plan only for a config too
322+
// old to carry fill data.
323+
const reb = rebalancedClose(obj, i, 'long') || this.#fullClose(obj, i, 'long');
314324
const quantity = reb ? reb.quantity : obj['SELL'][i].quantity;
315325
const price = reb ? reb.price : obj['SELL'][i].price;
316326

@@ -433,20 +443,12 @@ class Job {
433443
// the position is only partially closed. Don't end the cycle — pass yields
434444
// to the lower indices, which deliver a close for the leftover (guard above
435445
// + case FILLED with rebalancedClose).
436-
if (deepestFilledIndex(obj['BUY']) > i) {
446+
const D = deepestFilledIndex(obj['BUY']);
447+
if (D > i) {
437448
return { status: 'pass', method: false, side: null, id: i, data: {} };
438449
}
439450

440-
return {
441-
status: Status.DONE,
442-
method: 'cancelOpenOrders',
443-
side: null,
444-
id: i,
445-
data: {
446-
id: i,
447-
symbol: el.symbol,
448-
},
449-
};
451+
return this.#doneIfFlat(obj, i, el, D, 'long', 'SELL');
450452
}
451453
}
452454
};
@@ -513,7 +515,9 @@ class Job {
513515
if (obj['BUY'][i].manual) {
514516
return { status: 'pass', method: false, side: null, id: i, data: {} };
515517
}
516-
const reb = rebalancedClose(obj, i, 'short'); // null → precompute
518+
// Mirror of long: the slot plan is the last resort, not the first —
519+
// a pulled micro leaves its rung-sized numbers behind on the slot.
520+
const reb = rebalancedClose(obj, i, 'short') || this.#fullClose(obj, i, 'short');
517521
const quantity = reb ? reb.quantity : obj['BUY'][i].quantity;
518522
const price = reb ? reb.price : obj['BUY'][i].price;
519523

@@ -631,19 +635,12 @@ class Job {
631635
if (obj['BUY'][i].status === state.FILLED) {
632636
// Mirror of long: DONE is valid only if i is the deepest filled sell. A
633637
// filled sell remains below (k>i) → orphan, don't end the cycle.
634-
if (deepestFilledIndex(obj['SELL']) > i) {
638+
const D = deepestFilledIndex(obj['SELL']);
639+
if (D > i) {
635640
return { status: 'pass', method: false, side: null, id: i, data: {} };
636641
}
637642

638-
return {
639-
status: Status.DONE,
640-
method: 'cancelOpenOrders',
641-
side: null,
642-
id: i,
643-
data: {
644-
symbol: el.symbol,
645-
},
646-
};
643+
return this.#doneIfFlat(obj, i, el, D, 'short', 'BUY');
647644
}
648645
}
649646
};
@@ -761,6 +758,61 @@ class Job {
761758
return held.toDecimalPlaces(step, Decimal.ROUND_DOWN).lte(0);
762759
}
763760

761+
// A filled close ends the cycle ONLY if the books are square. Index reasoning
762+
// ("i is the deepest filled entry, so this close was THE close") is not enough:
763+
// a close sized for ONE RUNG fills at the deepest index too. That is what a
764+
// hybrid micro is — and once its 'role' marker is gone (it is deleted, never
765+
// re-derived) the classic machine cannot tell the two apart. Seen live: a micro
766+
// sold its one rung, the cycle went DONE on that fill, cancelOpenOrders pulled
767+
// the tail with it, and the rest of the position was left on the balance with
768+
// nothing on the book.
769+
//
770+
// So ask the books. While anything is still held, re-place the close for the
771+
// leftover: rebalancedClose nets out what the filled closes already sold, so it
772+
// is both sized and priced off what actually remains (the bank included). The
773+
// slot's fills survive the re-use — bankSlotFills carries them into
774+
// filledQty/filledQuote, which is what rebalancedClose reads next time.
775+
//
776+
// Falls back to the old behaviour where it cannot do better: a leftover under
777+
// the exchange minimum is dust (DONE + leftover, the user is notified), and a
778+
// config too old to carry fill data ends as it always did.
779+
#doneIfFlat(obj, i, el, D, strategy, closeSide) {
780+
const symbol = el.symbol;
781+
const done = (leftover) => ({
782+
status: Status.DONE,
783+
method: 'cancelOpenOrders',
784+
side: null,
785+
id: i,
786+
...(leftover ? { leftover } : {}),
787+
data: { id: i, symbol },
788+
});
789+
790+
if (this.#positionFlat(obj, D, strategy)) return done();
791+
792+
const reb = rebalancedClose(obj, i, strategy);
793+
if (!reb) return done(); // no fill data (old config) → classic ending
794+
795+
if (this.#belowMin(reb.quantity, reb.price)) {
796+
return done({ quantity: reb.quantity, price: reb.price, symbol });
797+
}
798+
799+
return {
800+
status: null,
801+
method: 'newOrder',
802+
side: closeSide,
803+
id: i,
804+
data: {
805+
id: i,
806+
symbol,
807+
side: closeSide,
808+
type: 'LIMIT',
809+
timeInForce: 'GTC',
810+
quantity: reb.quantity,
811+
price: reb.price,
812+
},
813+
};
814+
}
815+
764816
// The split line inside the pause gap: interpolate between the deepest rung's
765817
// REAL entry fill price and the whole-position close price recomputed from the
766818
// fills (#fullClose; slot-plan fallback only for old configs without fill

src/lib/telegram.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,16 @@ function flush(symbol) {
7979
buffers.delete(symbol);
8080
if (!buf || buf.length === 0) return;
8181

82-
const header = `----- ${symbol} ------`;
83-
const footer = '----------------------------';
82+
// Leading space so the batch header lines up with the single messages, which
83+
// start on a status icon of about that width.
84+
const header = ` ${symbol}`;
8485

8586
// Split into chunks so a very busy tick never exceeds Telegram's limit.
8687
let chunk = [];
8788
let len = 0;
8889
const sendChunk = () => {
8990
if (chunk.length === 0) return;
90-
send([header, ...chunk, footer].join('\n'));
91+
send([header, ...chunk].join('\n'));
9192
chunk = [];
9293
len = 0;
9394
};

src/modules/jsonTimerSender.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,7 +1269,7 @@ class JsonTimerSender extends EventEmitter {
12691269
// grid file unreadable — send without a price rather than skip the notice
12701270
}
12711271
telegram.send(
1272-
`🟢 <b>Start</b> ${this.symbol}\n` +
1272+
`🟩 <b>Start</b> ${this.symbol}\n` +
12731273
`Strategy: <b>${this.strategy}</b>\n` +
12741274
(startPrice ? `Price: <b>${startPrice}</b> ${this.quoteAsset || ''}\n` : '') +
12751275
`Auto-restart: <b>${this.autoRestart ? 'on' : 'off'}</b>`
@@ -1330,7 +1330,7 @@ class JsonTimerSender extends EventEmitter {
13301330
logBus.log(stopMsg);
13311331

13321332
if (wasRunning) {
1333-
telegram.send(`🛑 <b>Stop</b> ${this.symbol}`);
1333+
telegram.send(`🟨 <b>Pause</b> ${this.symbol}`);
13341334
}
13351335

13361336
await this.#emitRecovery();

src/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "spot-trading-bot",
33
"description": "DCA/Grid hybrid. A self-hosted, non-custodial spot bot that averages a position down, closes the whole grid at a profit, and scalps the oscillations in between.",
4-
"version": "2.0.3",
4+
"version": "2.0.4",
55
"private": true,
66
"license": "GPL-3.0-or-later",
77
"scripts": {

src/routes/spotbot.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ router.post('/series/delete', async (req, res) => {
509509

510510
logBus.clearSymbol(symbol);
511511

512-
telegram.send(`🗑 <b>Series deleted</b> ${symbol}`);
512+
telegram.send(`🟥 <b>Series deleted</b> ${symbol}`);
513513

514514
res.json({ success: true, message: 'No active orders.<br>Series deleted' });
515515
});

src/test/rungSizedClose.test.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)