backtest-kit 3.8.1 โ 4.0.1
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/README.md +903 -2
- package/build/index.cjs +3011 -138
- package/build/index.mjs +3003 -139
- package/package.json +1 -1
- package/types.d.ts +1648 -35
package/README.md
CHANGED
|
@@ -46,6 +46,7 @@ npm install backtest-kit ccxt ollama uuid
|
|
|
46
46
|
- ๐ **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
|
|
47
47
|
- ๐ก๏ธ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
|
|
48
48
|
- ๐ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
|
|
49
|
+
- ๐๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ exchange rejection rolls back the operation atomically.
|
|
49
50
|
- ๐งช **Tested**: 350+ unit/integration tests for validation, recovery, and events.
|
|
50
51
|
- ๐ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
|
|
51
52
|
|
|
@@ -208,9 +209,9 @@ Customize via `setConfig()`:
|
|
|
208
209
|
|
|
209
210
|
Backtest Kit is **not a data-processing library** - it is a **time execution engine**. Think of the engine as an **async stream of time**, where your strategy is evaluated step by step.
|
|
210
211
|
|
|
211
|
-
###
|
|
212
|
+
### ๐ How PNL Works
|
|
212
213
|
|
|
213
|
-
These three functions work together to manage
|
|
214
|
+
These three functions work together to dynamically manage the position. To reduce position linearity, by default, each DCA entry is formatted as a fixed **unit of $100**. This can be changed. No mathematical knowledge is required.
|
|
214
215
|
|
|
215
216
|
**Public API:**
|
|
216
217
|
- **`commitAverageBuy`** โ adds a new DCA entry. For LONG, **only accepted when current price is below a new low**. Silently rejected otherwise. This prevents averaging up. Can be overridden using `setConfig`
|
|
@@ -317,6 +318,906 @@ These three functions work together to manage a position dynamically. To reduce
|
|
|
317
318
|
|
|
318
319
|
**`priceOpen`** is the harmonic mean of all accepted DCA entries. After each partial close (`commitPartialProfit` or `commitPartialLoss`), the remaining cost basis is carried forward into the harmonic mean calculation for subsequent entries โ so `priceOpen` shifts after every partial, which in turn changes whether the next `commitAverageBuy` call will be accepted.
|
|
319
320
|
|
|
321
|
+
### ๐ How Broker Transactional Integrity Works
|
|
322
|
+
|
|
323
|
+
`Broker.useBrokerAdapter` connects a live exchange (ccxt, Binance, etc.) to the framework with transaction safety. Every commit method fires **before** the internal position state mutates. If the exchange rejects the order, the fill times out, or the network fails, the adapter throws, the mutation is skipped, and backtest-kit retries automatically on the next tick.
|
|
324
|
+
|
|
325
|
+
<details>
|
|
326
|
+
<summary>
|
|
327
|
+
The code
|
|
328
|
+
</summary>
|
|
329
|
+
|
|
330
|
+
**Spot**
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
import ccxt from "ccxt";
|
|
334
|
+
import { singleshot, sleep } from "functools-kit";
|
|
335
|
+
import {
|
|
336
|
+
Broker,
|
|
337
|
+
IBroker,
|
|
338
|
+
BrokerSignalOpenPayload,
|
|
339
|
+
BrokerSignalClosePayload,
|
|
340
|
+
BrokerPartialProfitPayload,
|
|
341
|
+
BrokerPartialLossPayload,
|
|
342
|
+
BrokerTrailingStopPayload,
|
|
343
|
+
BrokerTrailingTakePayload,
|
|
344
|
+
BrokerBreakevenPayload,
|
|
345
|
+
BrokerAverageBuyPayload,
|
|
346
|
+
} from "backtest-kit";
|
|
347
|
+
|
|
348
|
+
const FILL_POLL_INTERVAL_MS = 10_000;
|
|
349
|
+
const FILL_POLL_ATTEMPTS = 10;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Sleep between cancelOrder and fetchBalance to allow Binance to settle the
|
|
353
|
+
* cancellation โ reads immediately after cancel may return stale data.
|
|
354
|
+
*/
|
|
355
|
+
const CANCEL_SETTLE_MS = 2_000;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Slippage buffer for stop_loss_limit on Spot โ limit price is set slightly
|
|
359
|
+
* below stopPrice so the order fills even on a gap down instead of hanging.
|
|
360
|
+
*/
|
|
361
|
+
const STOP_LIMIT_SLIPPAGE = 0.995;
|
|
362
|
+
|
|
363
|
+
const getSpotExchange = singleshot(async () => {
|
|
364
|
+
const exchange = new ccxt.binance({
|
|
365
|
+
apiKey: process.env.BINANCE_API_KEY,
|
|
366
|
+
secret: process.env.BINANCE_API_SECRET,
|
|
367
|
+
options: {
|
|
368
|
+
defaultType: "spot",
|
|
369
|
+
adjustForTimeDifference: true,
|
|
370
|
+
recvWindow: 60000,
|
|
371
|
+
},
|
|
372
|
+
enableRateLimit: true,
|
|
373
|
+
});
|
|
374
|
+
await exchange.loadMarkets();
|
|
375
|
+
return exchange;
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Resolve base currency from market metadata โ safe for all quote currencies (USDT, USDC, FDUSD, etc.)
|
|
380
|
+
*/
|
|
381
|
+
function getBase(exchange: ccxt.binance, symbol: string): string {
|
|
382
|
+
return exchange.markets[symbol].base;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Truncate qty to exchange precision, always rounding down.
|
|
387
|
+
* Prevents over-selling due to floating point drift from fetchBalance.
|
|
388
|
+
*/
|
|
389
|
+
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
390
|
+
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Fetch current free balance for base currency of symbol.
|
|
395
|
+
*/
|
|
396
|
+
async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
|
|
397
|
+
const balance = await exchange.fetchBalance();
|
|
398
|
+
const base = getBase(exchange, symbol);
|
|
399
|
+
return parseFloat(String(balance?.free?.[base] ?? 0));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
404
|
+
* network blip) does not leave remaining orders uncancelled.
|
|
405
|
+
*/
|
|
406
|
+
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
407
|
+
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Place a stop_loss_limit sell order with a slippage buffer on the limit price.
|
|
412
|
+
* stop_loss_limit requires both stopPrice (trigger) and price (limit fill).
|
|
413
|
+
* Setting them equal risks non-fill on gap down โ limit is offset by STOP_LIMIT_SLIPPAGE.
|
|
414
|
+
*/
|
|
415
|
+
async function createStopLossOrder(
|
|
416
|
+
exchange: ccxt.binance,
|
|
417
|
+
symbol: string,
|
|
418
|
+
qty: number,
|
|
419
|
+
stopPrice: number
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
|
|
422
|
+
await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Place a limit order and poll until filled (status === "closed").
|
|
427
|
+
* On timeout: cancel the order, settle, check partial fill and sell it via market,
|
|
428
|
+
* restore SL/TP on remaining position so it is never left unprotected, then throw.
|
|
429
|
+
*/
|
|
430
|
+
async function createLimitOrderAndWait(
|
|
431
|
+
exchange: ccxt.binance,
|
|
432
|
+
symbol: string,
|
|
433
|
+
side: "buy" | "sell",
|
|
434
|
+
qty: number,
|
|
435
|
+
price: number,
|
|
436
|
+
restore?: { tpPrice: number; slPrice: number }
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
const order = await exchange.createOrder(symbol, "limit", side, qty, price);
|
|
439
|
+
|
|
440
|
+
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
441
|
+
await sleep(FILL_POLL_INTERVAL_MS);
|
|
442
|
+
const status = await exchange.fetchOrder(order.id, symbol);
|
|
443
|
+
if (status.status === "closed") {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await exchange.cancelOrder(order.id, symbol);
|
|
449
|
+
|
|
450
|
+
// Wait for Binance to settle the cancellation before reading filled qty
|
|
451
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
452
|
+
|
|
453
|
+
const final = await exchange.fetchOrder(order.id, symbol);
|
|
454
|
+
const filledQty = final.filled ?? 0;
|
|
455
|
+
|
|
456
|
+
if (filledQty > 0) {
|
|
457
|
+
// Sell partial fill via market to restore clean exchange state before backtest-kit retries
|
|
458
|
+
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
459
|
+
await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
463
|
+
if (restore) {
|
|
464
|
+
const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
465
|
+
if (remainingQty > 0) {
|
|
466
|
+
await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
|
|
467
|
+
await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ partial fill rolled back, backtest-kit will retry`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
Broker.useBrokerAdapter(
|
|
475
|
+
class implements IBroker {
|
|
476
|
+
|
|
477
|
+
async waitForInit(): Promise<void> {
|
|
478
|
+
await getSpotExchange();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
482
|
+
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
483
|
+
|
|
484
|
+
// Spot does not support short selling โ reject immediately so backtest-kit skips the mutation
|
|
485
|
+
if (position === "short") {
|
|
486
|
+
throw new Error(`SpotBrokerAdapter: short position is not supported on spot (symbol=${symbol})`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const exchange = await getSpotExchange();
|
|
490
|
+
|
|
491
|
+
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
492
|
+
|
|
493
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
494
|
+
if (qty <= 0) {
|
|
495
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
499
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
500
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
501
|
+
|
|
502
|
+
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
503
|
+
await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
|
|
504
|
+
|
|
505
|
+
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
506
|
+
try {
|
|
507
|
+
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
508
|
+
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
await exchange.createOrder(symbol, "market", "sell", qty);
|
|
511
|
+
throw err;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
516
|
+
const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
517
|
+
const exchange = await getSpotExchange();
|
|
518
|
+
|
|
519
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
520
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
521
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
522
|
+
|
|
523
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
524
|
+
|
|
525
|
+
// Position already closed by SL/TP on exchange โ nothing to do, commit succeeds
|
|
526
|
+
if (qty === 0) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
531
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
532
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
533
|
+
|
|
534
|
+
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
535
|
+
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
539
|
+
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
540
|
+
const exchange = await getSpotExchange();
|
|
541
|
+
|
|
542
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
543
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
544
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
545
|
+
|
|
546
|
+
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
547
|
+
|
|
548
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
549
|
+
if (totalQty === 0) {
|
|
550
|
+
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
554
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
555
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
556
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
557
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
558
|
+
|
|
559
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
560
|
+
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
561
|
+
|
|
562
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
563
|
+
if (remainingQty > 0) {
|
|
564
|
+
try {
|
|
565
|
+
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
566
|
+
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
567
|
+
} catch (err) {
|
|
568
|
+
// Remaining position is unprotected โ close via market
|
|
569
|
+
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
570
|
+
throw err;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
576
|
+
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
577
|
+
const exchange = await getSpotExchange();
|
|
578
|
+
|
|
579
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
580
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
581
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
582
|
+
|
|
583
|
+
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
584
|
+
|
|
585
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
586
|
+
if (totalQty === 0) {
|
|
587
|
+
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
591
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
592
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
593
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
594
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
595
|
+
|
|
596
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
597
|
+
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
598
|
+
|
|
599
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
600
|
+
if (remainingQty > 0) {
|
|
601
|
+
try {
|
|
602
|
+
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
603
|
+
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
// Remaining position is unprotected โ close via market
|
|
606
|
+
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
613
|
+
const { symbol, newStopLossPrice } = payload;
|
|
614
|
+
const exchange = await getSpotExchange();
|
|
615
|
+
|
|
616
|
+
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
617
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
618
|
+
const slOrder = orders.find((o) =>
|
|
619
|
+
o.side === "sell" &&
|
|
620
|
+
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
621
|
+
) ?? null;
|
|
622
|
+
if (slOrder) {
|
|
623
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
624
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
628
|
+
|
|
629
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
630
|
+
if (qty === 0) {
|
|
631
|
+
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
635
|
+
|
|
636
|
+
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
640
|
+
const { symbol, newTakeProfitPrice } = payload;
|
|
641
|
+
const exchange = await getSpotExchange();
|
|
642
|
+
|
|
643
|
+
// Cancel existing TP order only โ Spot has no reduceOnly, filter by side + type
|
|
644
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
645
|
+
const tpOrder = orders.find((o) =>
|
|
646
|
+
o.side === "sell" &&
|
|
647
|
+
["limit", "LIMIT"].includes(o.type ?? "")
|
|
648
|
+
) ?? null;
|
|
649
|
+
if (tpOrder) {
|
|
650
|
+
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
651
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
655
|
+
|
|
656
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
657
|
+
if (qty === 0) {
|
|
658
|
+
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
662
|
+
|
|
663
|
+
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
667
|
+
const { symbol, newStopLossPrice } = payload;
|
|
668
|
+
const exchange = await getSpotExchange();
|
|
669
|
+
|
|
670
|
+
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
671
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
672
|
+
const slOrder = orders.find((o) =>
|
|
673
|
+
o.side === "sell" &&
|
|
674
|
+
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
675
|
+
) ?? null;
|
|
676
|
+
if (slOrder) {
|
|
677
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
678
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
682
|
+
|
|
683
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
684
|
+
if (qty === 0) {
|
|
685
|
+
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
689
|
+
|
|
690
|
+
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
694
|
+
const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
|
|
695
|
+
const exchange = await getSpotExchange();
|
|
696
|
+
|
|
697
|
+
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
698
|
+
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
699
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
700
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
701
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
702
|
+
|
|
703
|
+
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
704
|
+
const existing = await fetchFreeQty(exchange, symbol);
|
|
705
|
+
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
706
|
+
|
|
707
|
+
// Compare notional value rather than raw qty โ avoids float === 0 trap
|
|
708
|
+
// and correctly rejects dust balances left over from previous trades
|
|
709
|
+
if (existing * currentPrice < minNotional) {
|
|
710
|
+
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
714
|
+
|
|
715
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
716
|
+
if (qty <= 0) {
|
|
717
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
721
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
722
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
723
|
+
|
|
724
|
+
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
725
|
+
await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
|
|
726
|
+
|
|
727
|
+
// Refetch balance after fill โ existing snapshot is stale after cancel + fill
|
|
728
|
+
const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
729
|
+
|
|
730
|
+
// Recreate SL/TP on fresh total qty after successful fill
|
|
731
|
+
try {
|
|
732
|
+
await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
|
|
733
|
+
await createStopLossOrder(exchange, symbol, totalQty, slPrice);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
// Total position is unprotected โ close via market
|
|
736
|
+
await exchange.createOrder(symbol, "market", "sell", totalQty);
|
|
737
|
+
throw err;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
Broker.enable();
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
**Futures**
|
|
747
|
+
|
|
748
|
+
```typescript
|
|
749
|
+
import ccxt from "ccxt";
|
|
750
|
+
import { singleshot, sleep } from "functools-kit";
|
|
751
|
+
import {
|
|
752
|
+
Broker,
|
|
753
|
+
IBroker,
|
|
754
|
+
BrokerSignalOpenPayload,
|
|
755
|
+
BrokerSignalClosePayload,
|
|
756
|
+
BrokerPartialProfitPayload,
|
|
757
|
+
BrokerPartialLossPayload,
|
|
758
|
+
BrokerTrailingStopPayload,
|
|
759
|
+
BrokerTrailingTakePayload,
|
|
760
|
+
BrokerBreakevenPayload,
|
|
761
|
+
BrokerAverageBuyPayload,
|
|
762
|
+
} from "backtest-kit";
|
|
763
|
+
|
|
764
|
+
const FILL_POLL_INTERVAL_MS = 10_000;
|
|
765
|
+
const FILL_POLL_ATTEMPTS = 10;
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Sleep between cancelOrder and fetchPositions to allow Binance to settle the
|
|
769
|
+
* cancellation โ reads immediately after cancel may return stale data.
|
|
770
|
+
*/
|
|
771
|
+
const CANCEL_SETTLE_MS = 2_000;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* 3x leverage โ conservative choice for $1000 total fiat.
|
|
775
|
+
* Enough to matter, not enough to liquidate on normal volatility.
|
|
776
|
+
* Applied per-symbol on first open via setLeverage.
|
|
777
|
+
*/
|
|
778
|
+
const FUTURES_LEVERAGE = 3;
|
|
779
|
+
|
|
780
|
+
const getFuturesExchange = singleshot(async () => {
|
|
781
|
+
const exchange = new ccxt.binance({
|
|
782
|
+
apiKey: process.env.BINANCE_API_KEY,
|
|
783
|
+
secret: process.env.BINANCE_API_SECRET,
|
|
784
|
+
options: {
|
|
785
|
+
defaultType: "future",
|
|
786
|
+
adjustForTimeDifference: true,
|
|
787
|
+
recvWindow: 60000,
|
|
788
|
+
},
|
|
789
|
+
enableRateLimit: true,
|
|
790
|
+
});
|
|
791
|
+
await exchange.loadMarkets();
|
|
792
|
+
return exchange;
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Truncate qty to exchange precision, always rounding down.
|
|
797
|
+
* Prevents over-selling due to floating point drift from fetchPositions.
|
|
798
|
+
*/
|
|
799
|
+
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
800
|
+
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Resolve position for symbol filtered by side โ safe in both one-way and hedge mode.
|
|
805
|
+
*/
|
|
806
|
+
function findPosition(positions: ccxt.Position[], symbol: string, side: "long" | "short") {
|
|
807
|
+
// Hedge mode: positions have explicit side field
|
|
808
|
+
const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
|
|
809
|
+
if (hedged) {
|
|
810
|
+
return hedged;
|
|
811
|
+
}
|
|
812
|
+
// One-way mode: single position per symbol, side field may be undefined or mismatched
|
|
813
|
+
const pos = positions.find((p) => p.symbol === symbol) ?? null;
|
|
814
|
+
if (pos && pos.side && pos.side !== side) {
|
|
815
|
+
console.warn(`findPosition: expected side="${side}" but exchange returned side="${pos.side}" for ${symbol} โ possible one-way/hedge mode mismatch`);
|
|
816
|
+
}
|
|
817
|
+
return pos;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Fetch current contracts qty for symbol/side.
|
|
822
|
+
*/
|
|
823
|
+
async function fetchContractsQty(
|
|
824
|
+
exchange: ccxt.binance,
|
|
825
|
+
symbol: string,
|
|
826
|
+
side: "long" | "short"
|
|
827
|
+
): Promise<number> {
|
|
828
|
+
const positions = await exchange.fetchPositions([symbol]);
|
|
829
|
+
const pos = findPosition(positions, symbol, side);
|
|
830
|
+
return Math.abs(parseFloat(String(pos?.contracts ?? 0)));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
835
|
+
* network blip) does not leave remaining orders uncancelled.
|
|
836
|
+
*/
|
|
837
|
+
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
838
|
+
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Resolve Binance positionSide string from position direction.
|
|
843
|
+
* Required in hedge mode to correctly route orders; ignored in one-way mode.
|
|
844
|
+
*/
|
|
845
|
+
function toPositionSide(position: "long" | "short"): "LONG" | "SHORT" {
|
|
846
|
+
return position === "long" ? "LONG" : "SHORT";
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Place a limit order and poll until filled (status === "closed").
|
|
851
|
+
* On timeout: cancel the order, settle, check partial fill and close it via market,
|
|
852
|
+
* restore SL/TP on remaining position so it is never left unprotected, then throw.
|
|
853
|
+
*
|
|
854
|
+
* positionSide is forwarded into rollback market order so hedge mode accounts
|
|
855
|
+
* correctly route the close without -4061 error.
|
|
856
|
+
*/
|
|
857
|
+
async function createLimitOrderAndWait(
|
|
858
|
+
exchange: ccxt.binance,
|
|
859
|
+
symbol: string,
|
|
860
|
+
side: "buy" | "sell",
|
|
861
|
+
qty: number,
|
|
862
|
+
price: number,
|
|
863
|
+
params: Record<string, unknown> = {},
|
|
864
|
+
restore?: { exitSide: "buy" | "sell"; tpPrice: number; slPrice: number; positionSide: "long" | "short" }
|
|
865
|
+
): Promise<void> {
|
|
866
|
+
const order = await exchange.createOrder(symbol, "limit", side, qty, price, params);
|
|
867
|
+
|
|
868
|
+
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
869
|
+
await sleep(FILL_POLL_INTERVAL_MS);
|
|
870
|
+
const status = await exchange.fetchOrder(order.id, symbol);
|
|
871
|
+
if (status.status === "closed") {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
await exchange.cancelOrder(order.id, symbol);
|
|
877
|
+
|
|
878
|
+
// Wait for Binance to settle the cancellation before reading filled qty
|
|
879
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
880
|
+
|
|
881
|
+
const final = await exchange.fetchOrder(order.id, symbol);
|
|
882
|
+
const filledQty = final.filled ?? 0;
|
|
883
|
+
|
|
884
|
+
if (filledQty > 0) {
|
|
885
|
+
// Close partial fill via market โ positionSide required in hedge mode (-4061 without it)
|
|
886
|
+
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
887
|
+
const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
|
|
888
|
+
await exchange.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
|
|
889
|
+
reduceOnly: true,
|
|
890
|
+
...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
895
|
+
if (restore) {
|
|
896
|
+
const remainingQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, restore.positionSide));
|
|
897
|
+
if (remainingQty > 0) {
|
|
898
|
+
await exchange.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
|
|
899
|
+
await exchange.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ partial fill rolled back, backtest-kit will retry`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
Broker.useBrokerAdapter(
|
|
907
|
+
class implements IBroker {
|
|
908
|
+
|
|
909
|
+
async waitForInit(): Promise<void> {
|
|
910
|
+
await getFuturesExchange();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
914
|
+
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
915
|
+
const exchange = await getFuturesExchange();
|
|
916
|
+
|
|
917
|
+
// Set leverage before entry โ ensures consistent leverage regardless of previous session state
|
|
918
|
+
await exchange.setLeverage(FUTURES_LEVERAGE, symbol);
|
|
919
|
+
|
|
920
|
+
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
921
|
+
|
|
922
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
923
|
+
if (qty <= 0) {
|
|
924
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
928
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
929
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
930
|
+
const entrySide = position === "long" ? "buy" : "sell";
|
|
931
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
932
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
933
|
+
const positionSide = toPositionSide(position);
|
|
934
|
+
|
|
935
|
+
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
936
|
+
await createLimitOrderAndWait(exchange, symbol, entrySide, qty, openPrice, { positionSide });
|
|
937
|
+
|
|
938
|
+
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
939
|
+
try {
|
|
940
|
+
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
941
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
942
|
+
} catch (err) {
|
|
943
|
+
await exchange.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
949
|
+
const { symbol, position, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
950
|
+
const exchange = await getFuturesExchange();
|
|
951
|
+
|
|
952
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
953
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
954
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
955
|
+
|
|
956
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
957
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
958
|
+
|
|
959
|
+
// Position already closed by SL/TP on exchange โ throw so backtest-kit can reconcile
|
|
960
|
+
// the close price via its own mechanism rather than assuming a successful manual close
|
|
961
|
+
if (qty === 0) {
|
|
962
|
+
throw new Error(`SignalClose skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
966
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
967
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
968
|
+
|
|
969
|
+
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
970
|
+
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
971
|
+
await createLimitOrderAndWait(
|
|
972
|
+
exchange, symbol, exitSide, qty, closePrice,
|
|
973
|
+
{ reduceOnly: true },
|
|
974
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
979
|
+
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
980
|
+
const exchange = await getFuturesExchange();
|
|
981
|
+
|
|
982
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
983
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
984
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
985
|
+
|
|
986
|
+
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
987
|
+
|
|
988
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
989
|
+
if (totalQty === 0) {
|
|
990
|
+
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
994
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
995
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
996
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
997
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
998
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
999
|
+
const positionSide = toPositionSide(position);
|
|
1000
|
+
|
|
1001
|
+
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1002
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1003
|
+
await createLimitOrderAndWait(
|
|
1004
|
+
exchange, symbol, exitSide, qty, closePrice,
|
|
1005
|
+
{ reduceOnly: true },
|
|
1006
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
1010
|
+
if (remainingQty > 0) {
|
|
1011
|
+
try {
|
|
1012
|
+
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1013
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
// Remaining position is unprotected โ close via market
|
|
1016
|
+
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
1023
|
+
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1024
|
+
const exchange = await getFuturesExchange();
|
|
1025
|
+
|
|
1026
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1027
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1028
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1029
|
+
|
|
1030
|
+
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1031
|
+
|
|
1032
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1033
|
+
if (totalQty === 0) {
|
|
1034
|
+
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1038
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1039
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1040
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1041
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1042
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1043
|
+
const positionSide = toPositionSide(position);
|
|
1044
|
+
|
|
1045
|
+
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1046
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1047
|
+
await createLimitOrderAndWait(
|
|
1048
|
+
exchange, symbol, exitSide, qty, closePrice,
|
|
1049
|
+
{ reduceOnly: true },
|
|
1050
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
1054
|
+
if (remainingQty > 0) {
|
|
1055
|
+
try {
|
|
1056
|
+
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1057
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
// Remaining position is unprotected โ close via market
|
|
1060
|
+
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1061
|
+
throw err;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
1067
|
+
const { symbol, newStopLossPrice, position } = payload;
|
|
1068
|
+
const exchange = await getFuturesExchange();
|
|
1069
|
+
|
|
1070
|
+
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1071
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1072
|
+
const slOrder = orders.find((o) =>
|
|
1073
|
+
!!o.reduceOnly &&
|
|
1074
|
+
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1075
|
+
) ?? null;
|
|
1076
|
+
if (slOrder) {
|
|
1077
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1078
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1082
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1083
|
+
|
|
1084
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1085
|
+
if (qty === 0) {
|
|
1086
|
+
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1090
|
+
const positionSide = toPositionSide(position);
|
|
1091
|
+
|
|
1092
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1093
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
1097
|
+
const { symbol, newTakeProfitPrice, position } = payload;
|
|
1098
|
+
const exchange = await getFuturesExchange();
|
|
1099
|
+
|
|
1100
|
+
// Cancel existing TP order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1101
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1102
|
+
const tpOrder = orders.find((o) =>
|
|
1103
|
+
!!o.reduceOnly &&
|
|
1104
|
+
["limit", "LIMIT"].includes(o.type ?? "")
|
|
1105
|
+
) ?? null;
|
|
1106
|
+
if (tpOrder) {
|
|
1107
|
+
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
1108
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1112
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1113
|
+
|
|
1114
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1115
|
+
if (qty === 0) {
|
|
1116
|
+
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
1120
|
+
const positionSide = toPositionSide(position);
|
|
1121
|
+
|
|
1122
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1123
|
+
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
1127
|
+
const { symbol, newStopLossPrice, position } = payload;
|
|
1128
|
+
const exchange = await getFuturesExchange();
|
|
1129
|
+
|
|
1130
|
+
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1131
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1132
|
+
const slOrder = orders.find((o) =>
|
|
1133
|
+
!!o.reduceOnly &&
|
|
1134
|
+
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1135
|
+
) ?? null;
|
|
1136
|
+
if (slOrder) {
|
|
1137
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1138
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1142
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1143
|
+
|
|
1144
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1145
|
+
if (qty === 0) {
|
|
1146
|
+
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1150
|
+
const positionSide = toPositionSide(position);
|
|
1151
|
+
|
|
1152
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1153
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
1157
|
+
const { symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1158
|
+
const exchange = await getFuturesExchange();
|
|
1159
|
+
|
|
1160
|
+
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
1161
|
+
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
1162
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1163
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1164
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1165
|
+
|
|
1166
|
+
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
1167
|
+
const existing = await fetchContractsQty(exchange, symbol, position);
|
|
1168
|
+
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
1169
|
+
|
|
1170
|
+
// Compare notional value rather than raw contracts โ avoids float === 0 trap
|
|
1171
|
+
// and correctly rejects dust positions left over from previous trades
|
|
1172
|
+
if (existing * currentPrice < minNotional) {
|
|
1173
|
+
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
1177
|
+
|
|
1178
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
1179
|
+
if (qty <= 0) {
|
|
1180
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1184
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1185
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1186
|
+
// positionSide required in hedge mode to add to correct side; ignored in one-way mode
|
|
1187
|
+
const positionSide = toPositionSide(position);
|
|
1188
|
+
const entrySide = position === "long" ? "buy" : "sell";
|
|
1189
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1190
|
+
|
|
1191
|
+
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
1192
|
+
await createLimitOrderAndWait(
|
|
1193
|
+
exchange, symbol, entrySide, qty, entryPrice,
|
|
1194
|
+
{ positionSide },
|
|
1195
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
// Refetch contracts after fill โ existing snapshot is stale after cancel + fill
|
|
1199
|
+
const totalQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1200
|
+
|
|
1201
|
+
// Recreate SL/TP on fresh total qty after successful fill
|
|
1202
|
+
try {
|
|
1203
|
+
await exchange.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1204
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
// Total position is unprotected โ close via market
|
|
1207
|
+
await exchange.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
|
|
1208
|
+
throw err;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
Broker.enable();
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
</details>
|
|
1218
|
+
|
|
1219
|
+
Signal open/close events are routed automatically via an internal event bus once `Broker.enable()` is called. **No manual wiring needed.** All other operations (`partialProfit`, `trailingStop`, `breakeven`, `averageBuy`) are intercepted explicitly before the corresponding state mutation.
|
|
1220
|
+
|
|
320
1221
|
### ๐ How getCandles Works
|
|
321
1222
|
|
|
322
1223
|
backtest-kit uses Node.js `AsyncLocalStorage` to automatically provide
|