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 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
- ### ๐Ÿ’ฐ How PNL Works
212
+ ### ๐Ÿ” How PNL Works
212
213
 
213
- These three functions work together to manage a position dynamically. To reduce position linearity, the framework treats every DCA entry as a fixed **$100 unit** regardless of price โ€” this flattens the effective entry curve and makes PNL weighting independent of position size.
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