forked from tripolskypetr/backtest-kit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLLMs.md
More file actions
3815 lines (2935 loc) · 240 KB
/
Copy pathLLMs.md
File metadata and controls
3815 lines (2935 loc) · 240 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<img src="https://github.com/tripolskypetr/backtest-kit/raw/refs/heads/master/assets/consciousness.svg" height="45px" align="right">
# 🧿 Backtest Kit — Technical Reference for Language Models
> Exhaustive, source-accurate API reference for **backtest-kit** `v13.6.0`. This document is written for language models and machine consumption: every symbol below is exported from `backtest-kit` and verified against `./src`. The human-facing narrative lives in [README.md](./README.md); this file is the dense, complete specification.
`backtest-kit` is a TypeScript framework for backtesting and live trading strategies on multi-asset markets (crypto, forex, DEX/peer-to-peer, spot, futures) with crash-safe persistence, signal validation, transactional broker integration, virtual-time scheduling, and AI/LLM optimization. It is **not a data-processing library — it is a time execution engine**: an async stream of virtual (backtest) or real (live) time, where your strategy is evaluated tick by tick, and the *same strategy code* runs unchanged in both modes.
---
## Table of Contents
1. [Mental model & guarantees](#1-mental-model--guarantees)
2. [Installation & ecosystem packages](#2-installation--ecosystem-packages)
3. [Quick start](#3-quick-start)
4. [Core concepts](#4-core-concepts)
5. [The execution model (tick stream, contexts, look-ahead protection)](#5-the-execution-model)
6. [Schema registration reference](#6-schema-registration-reference)
7. [Signal lifecycle & tick results](#7-signal-lifecycle--tick-results)
8. [Strategy context functions](#8-strategy-context-functions)
9. [Commit functions (position mutation API)](#9-commit-functions)
10. [Position analytics functions](#10-position-analytics-functions)
11. [Exchange data API & candle math](#11-exchange-data-api--candle-math)
12. [PNL, DCA & effective-price math](#12-pnl-dca--effective-price-math)
13. [Runners: Backtest, Live, Walker](#13-runners-backtest-live-walker)
14. [Analytics & reports: Heat, Schedule, Partial, Position, HighestProfit, MaxDrawdown, Risk, Performance, Sync](#14-analytics--reports)
15. [Position sizing](#15-position-sizing)
16. [Risk management](#16-risk-management)
17. [Broker: transactional live orders](#17-broker-transactional-live-orders)
18. [Cron: virtual-time scheduler](#18-cron-virtual-time-scheduler)
19. [Sync: order synchronization](#19-sync-order-synchronization)
20. [Per-signal Memory, State, Session, Storage, Recent](#20-per-signal-memory-state-session-storage-recent)
21. [Dump: agent reasoning & record capture](#21-dump-agent-reasoning--record-capture)
22. [Actions: pluggable event handlers](#22-actions-pluggable-event-handlers)
23. [Event listeners](#23-event-listeners)
24. [Notifications](#24-notifications)
25. [Persistence adapters (15 domains)](#25-persistence-adapters)
26. [Global configuration reference](#26-global-configuration-reference)
27. [Math helpers & utilities](#27-math-helpers--utilities)
28. [Reflection & introspection](#28-reflection--introspection)
29. [AI strategy optimizer](#29-ai-strategy-optimizer)
30. [Strategy examples](#30-strategy-examples)
31. [Architecture overview](#31-architecture-overview)
32. [Complete public export index](#32-complete-public-export-index)
33. [Ecosystem packages — detailed API](#33-ecosystem-packages--detailed-api)
34. [Strategy examples (reference implementations)](#34-strategy-examples-reference-implementations)
35. [Raw-library demos (no CLI)](#35-raw-library-demos-no-cli)
36. [Framework philosophy & further reading](#36-framework-philosophy--further-reading)
37. [Markdown report catalog](#37-markdown-report-catalog)
38. [JSONL report streams](#38-jsonl-report-streams)
39. [Schema & graph validation](#39-schema--graph-validation)
40. [Config in practice — where each parameter is consumed](#40-config-in-practice--where-each-parameter-is-consumed)
41. [Binding the framework to a real exchange — EXCEPTION-BASED vs EVENT-BASED](#41-binding-the-framework-to-a-real-exchange--exception-based-vs-event-based)
---
## 1. Mental model & guarantees
Think of the engine as an **async stream of time**. Each emitted moment is a *tick*. On each tick the engine:
1. Computes the current price (VWAP of the last `CC_AVG_PRICE_CANDLES_COUNT` 1-minute candles).
2. Asks your strategy for a signal (throttled to the strategy `interval`).
3. Monitors any open position for TP / SL / time expiry / trailing / breakeven / partial milestones.
4. Emits lifecycle events (`idle`, `scheduled`, `waiting`, `opened`, `active`, `closed`, `cancelled`) plus ping events (`idlePing`, `schedulePing`, `activePing`).
The headline guarantees:
- **Mode parity.** The identical strategy file runs in `Backtest` (historical, virtual time) and `Live` (real time). The only difference is the clock source — handled by the framework via `AsyncLocalStorage`.
- **No look-ahead bias.** All candle/orderbook/trade fetches are aligned down to interval boundaries and clamped to the current virtual `when`. It is structurally impossible for a strategy to read a candle from its own future. See [§11](#11-exchange-data-api--candle-math).
- **Crash-safe persistence.** Live state (pending signals, scheduled signals, partial levels, breakeven flags, risk positions, strategy commit queue, …) is written atomically and restored on restart — no duplicate signals, no lost positions. 15 independent persistence domains, each replaceable with a custom adapter ([§25](#25-persistence-adapters)).
- **Transactional live orders.** The optional `Broker` adapter intercepts every position mutation *before* internal state changes; an exchange rejection rolls back the operation atomically and the engine retries on the next tick ([§17](#17-broker-transactional-live-orders)).
- **Path-aware exits.** Exits are evaluated against OHLC replay within each candle, not close-to-close — so intra-candle SL/TP hits are detected in the correct order.
- **Safe math.** Every statistic is guarded against `NaN`/`Infinity`; invalid computations surface as `null` / `N/A` rather than poisoning a report.
`backtest-kit` has **775+** unit and integration tests covering validation, recovery, reports, events, walker, heatmap, position sizing, risk, scheduled signals, partials, breakeven, trailing, DCA, cron, sync, and broker.
> The *why* behind these design choices — look-ahead bias as an architectural constraint, second-order chaos, the zero-expectation trap, AI-driven strategy development, the monorepo/parallel model — is laid out in [§36 Framework philosophy & further reading](#36-framework-philosophy--further-reading).
---
## 2. Installation & ecosystem packages
### Core install
```bash
npm install backtest-kit ccxt ollama uuid
```
`backtest-kit` has only four runtime dependencies (`di-kit`, `di-scoped`, `di-singleton`, `functools-kit`, `get-moment-stamp`) and requires `typescript ^5.0.0` as a peer dependency. `ccxt`, `ollama`, `uuid` are *your* peers for data fetching and LLM inference — not required by the core.
### Scaffolding (fastest paths)
```bash
# Zero-boilerplate: all wiring stays inside the CLI package
npx @backtest-kit/cli --init --output backtest-kit-project
cd backtest-kit-project && npm install && npm start
# Full-control eject: exchange/frame/risk/strategy/runner all live as editable files
npx -y @backtest-kit/sidekick my-trading-bot
cd my-trading-bot && npm start
# Docker workspace with auto-restart
npx @backtest-kit/cli --docker
cd backtest-kit-docker
MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
```
### Ecosystem packages
| Package | Purpose |
| --- | --- |
| `@backtest-kit/cli` | Zero-boilerplate CLI runner. `--backtest` / `--paper` / `--live`, auto candle cache, `--ui`, `--telegram`, monorepo isolation. |
| `@backtest-kit/sidekick` | Project scaffolder — the "eject" of `--init`; all boilerplate as editable source files. |
| `@backtest-kit/pinets` | Run TradingView Pine Script v5/v6 strategies in Node via the PineTS runtime; 60+ built-in indicators. |
| `@backtest-kit/graph` | Compose computations as a typed DAG (`sourceNode` + `outputNode`), resolved in topological order with `Promise.all` parallelism. |
| `@backtest-kit/ui` | Full-stack visualization: Node backend + React dashboard, candlestick charts, signal tracking, risk analysis. |
| `@backtest-kit/mongo` | MongoDB source-of-truth + Redis O(1) cache replacing file-based `./dump/`; all 15 persist adapters implemented. |
| `@backtest-kit/ollama` | Multi-provider LLM inference (OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, HuggingFace, Ollama) with structured JSON output and token rotation. |
| `@backtest-kit/signals` | 50+ technical indicators across 4 timeframes; markdown reports formatted for LLM context injection. |
> Full per-package API reference (verified against each `src/index.ts`) is in [§33](#33-ecosystem-packages--detailed-api).
Community templates: `backtest-monorepo-parallel` (9 symbols in one process on Mongo+Redis), `backtest-ollama-crontab` (Telegram-ingested signals + LLM risk filter), `backtest-kit-redis-mongo-docker` (production persistence stack), `uzse-backtest-app` (regional stock exchanges via Pine Script).
Vector/quant math companions, plugging into the `Exchange` schema with no Python runtime: [`garch`](https://www.npmjs.com/package/garch) (conditional variance → TP/SL corridor, via `getCandles`), [`pump-anomaly`](https://www.npmjs.com/package/pump-anomaly) (coordinated-speculation detection, via `getRawCandles`), [`volume-anomaly`](https://www.npmjs.com/package/volume-anomaly) (order-flow intensity, via `getAggregatedTrades`).
---
## 3. Quick start
### 3.1 Basic configuration
```typescript
import { setLogger, setConfig } from "backtest-kit";
setLogger({
log: console.log,
debug: console.debug,
info: console.info,
warn: console.warn,
});
// Optional — see §26 for all ~40 keys. Call before running any strategy.
setConfig({
CC_PERCENT_SLIPPAGE: 0.1, // % slippage per side
CC_PERCENT_FEE: 0.1, // % fee per side
CC_SCHEDULE_AWAIT_MINUTES: 120, // pending (scheduled) signal timeout
});
```
> `setLogger`/`setConfig` are synchronous in `v13.6.0` (the README's `await setConfig(...)` still works because `await` on a non-promise is a no-op, but it is not required). `setConfig` validates the merged config and rolls back + rethrows on failure.
### 3.2 Register components
```typescript
import ccxt from "ccxt";
import {
addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema,
} from "backtest-kit";
// Exchange (data source)
addExchangeSchema({
exchangeName: "binance",
getCandles: async (symbol, interval, since, limit, backtest) => {
const exchange = new ccxt.binance();
const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({
timestamp, open, high, low, close, volume,
}));
},
formatPrice: (symbol, price) => price.toFixed(2),
formatQuantity: (symbol, quantity) => quantity.toFixed(8),
});
// Risk profile (portfolio-level validations)
addRiskSchema({
riskName: "demo",
validations: [
// TP at least 1%
({ currentSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, position } = currentSignal;
const tpDistance = position === "long"
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
},
// Reward/Risk at least 2:1
({ currentSignal }) => {
const { priceOpen, priceTakeProfit, priceStopLoss, position } = currentSignal;
const reward = position === "long" ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
const risk = position === "long" ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
if (reward / risk < 2) throw new Error("Poor R/R ratio");
},
],
});
// Time frame (backtest period)
addFrameSchema({
frameName: "1d-test",
interval: "1m",
startDate: new Date("2025-12-01"),
endDate: new Date("2025-12-02"),
});
```
> **Note on the risk validation payload.** In `v13.6.0` the validation function receives an `IRiskValidationPayload` whose signal field is `currentSignal` (an `IRiskSignalRow`, with `priceOpen` always present), plus `activePositionCount` and `activePositions`. Earlier docs referenced `pendingSignal` — use `currentSignal`. See [§16](#16-risk-management).
### 3.3 Define a strategy (with an LLM)
```typescript
import { v4 as uuid } from "uuid";
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from "backtest-kit";
import { json } from "./utils/json.mjs"; // your LLM wrapper
import { getMessages } from "./utils/messages.mjs"; // market-data prep
addStrategySchema({
strategyName: "llm-strategy",
interval: "5m",
riskName: "demo",
getSignal: async (symbol, when, currentPrice) => {
const candles1h = await getCandles(symbol, "1h", 24);
const candles15m = await getCandles(symbol, "15m", 48);
const candles5m = await getCandles(symbol, "5m", 60);
const candles1m = await getCandles(symbol, "1m", 60);
const messages = await getMessages(symbol, { candles1h, candles15m, candles5m, candles1m });
const resultId = uuid();
const signal = await json(messages); // LLM returns { position, priceTakeProfit, priceStopLoss, ... }
await dumpAgentAnswer({
dumpId: "position-context",
bucketName: "multi-timeframe-strategy",
messages,
description: "agent reasoning for this signal",
});
await dumpRecord({
dumpId: "position-entry",
bucketName: "multi-timeframe-strategy",
record: signal,
description: "signal entry parameters",
});
return { ...signal, id: resultId };
},
});
```
> **`getSignal` signature changed.** In `v13.6.0` `getSignal` is `(symbol, when, currentPrice) => Promise<ISignalDto | null>`. The `when: Date` and `currentPrice: number` arguments are passed by the engine; you no longer have to call `getDate()`/`getAveragePrice()` just to obtain them (though those functions still exist). Returning `null` means "no signal this tick".
### 3.4 Run a backtest
```typescript
import { Backtest, listenSignalBacktest, listenDoneBacktest } from "backtest-kit";
Backtest.background("BTCUSDT", {
strategyName: "llm-strategy",
exchangeName: "binance",
frameName: "1d-test",
});
listenSignalBacktest((event) => console.log(event));
listenDoneBacktest(async (event) => {
await Backtest.dump(event.symbol, event.strategyName); // → ./dump/backtest/llm-strategy.md
});
```
### 3.5 Run live trading
```typescript
import { Live, listenSignalLive } from "backtest-kit";
Live.background("BTCUSDT", {
strategyName: "llm-strategy",
exchangeName: "binance",
});
listenSignalLive((event) => console.log(event));
```
### 3.6 Complete end-to-end example (single file)
A minimal, fully self-contained backtest with a deterministic synthetic exchange — useful as a smoke test or a starting skeleton:
```typescript
import {
addExchangeSchema, addStrategySchema, addFrameSchema,
Backtest, listenSignalBacktest, listenDoneBacktest, listenError,
} from "backtest-kit";
// 1) Exchange — synthetic candles around a slowly drifting base price.
addExchangeSchema({
exchangeName: "sim",
getCandles: async (symbol, interval, since, limit) => {
const base = 50_000;
return Array.from({ length: limit }, (_, i) => {
const t = since.getTime() + i * 60_000;
const p = base + Math.sin(t / 6e6) * 500;
return { timestamp: t, open: p, high: p + 25, low: p - 25, close: p, volume: 10 };
});
},
formatPrice: (s, price) => price.toFixed(2),
formatQuantity: (s, qty) => qty.toFixed(6),
});
// 2) Strategy — open a LONG immediately when flat, scale out at +1%, trail at +2%.
addStrategySchema({
strategyName: "demo",
interval: "5m",
getSignal: async (symbol, when, currentPrice) => {
if (!(await hasNoPendingSignal(symbol))) return null;
return {
position: "long",
priceTakeProfit: currentPrice * 1.03,
priceStopLoss: currentPrice * 0.98,
minuteEstimatedTime: 240,
};
},
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
const pct = await getPositionPnlPercent(symbol, currentPrice);
if (pct !== null && pct >= 1) await commitPartialProfit(symbol, 50);
if (pct !== null && pct >= 2) await commitTrailingStop(symbol, 1, currentPrice);
},
onClose: (symbol, data, priceClose, when) =>
console.log(`closed ${symbol} @ ${priceClose} pnl=${data.pnl.pnlPercentage.toFixed(2)}%`),
},
});
// 3) Frame — one day at 1-minute granularity.
addFrameSchema({
frameName: "1d",
interval: "1m",
startDate: new Date("2025-12-01T00:00:00Z"),
endDate: new Date("2025-12-02T00:00:00Z"),
});
// 4) Run + report.
listenError((e) => console.error("engine error:", e));
listenSignalBacktest((e) => { if (e.action === "closed") console.log("PNL%", e.pnl.pnlPercentage); });
listenDoneBacktest(async (e) => { await Backtest.dump(e.symbol, e.strategyName); });
Backtest.background("BTCUSDT", { strategyName: "demo", exchangeName: "sim", frameName: "1d" });
```
> Imports of `hasNoPendingSignal`, `getPositionPnlPercent`, `commitPartialProfit`, `commitTrailingStop` come from `backtest-kit` (omitted above for brevity). They are valid inside `getSignal`/callbacks because the engine has an active context there.
---
## 4. Core concepts
### 4.1 Dependency inversion via string names
Exchanges, strategies, frames, risk profiles, sizing profiles, walkers, and actions are registered under **string identifiers** and lazily resolved at runtime. Declare them in separate modules, wire them with constants:
```typescript
export enum ExchangeName { Binance = "binance", Bybit = "bybit" }
export enum StrategyName { SMA = "sma-crossover", RSI = "rsi-strategy" }
export enum FrameName { Day = "1d", Week = "1w" }
addStrategySchema({ strategyName: StrategyName.SMA, interval: "5m", getSignal: async () => { /* … */ } });
Backtest.background("BTCUSDT", {
strategyName: StrategyName.SMA,
exchangeName: ExchangeName.Binance,
frameName: FrameName.Day,
});
```
All name types (`ExchangeName`, `StrategyName`, `FrameName`, `RiskName`, `SizingName`, `WalkerName`, `ActionName`) are `string` aliases — use plain strings or enums interchangeably.
### 4.2 The `context` object
Every runner method takes `(symbol, context)`. The shape depends on the runner:
- **Backtest**: `{ strategyName, exchangeName, frameName }`
- **Live**: `{ strategyName, exchangeName }` (no frame — live uses the wall clock)
- **Walker**: `{ walkerName }` (the walker schema already names the exchange + frame + strategy list)
### 4.3 Two ways to run the engine
Both consume the **same engine with the same guarantees**; only the consumption model differs.
**Event-driven (background):** for production bots, monitoring, long-running processes.
```typescript
Backtest.background("BTCUSDT", config);
listenSignalBacktest((event) => { /* handle every lifecycle event */ });
listenDoneBacktest((event) => { /* finalize / dump report */ });
```
**Async iterator (pull-based):** for research, scripting, tests, and LLM agents.
```typescript
for await (const event of Backtest.run("BTCUSDT", config)) {
// backtest yields closed/cancelled/opened/scheduled/active results
}
```
`background(...)` returns a **cancellation closure** (graceful stop — lets the current position finish). `run(...)` returns an **async generator**.
### 4.4 Signals: scheduled vs immediate
`getSignal` returns an `ISignalDto`:
- If `priceOpen` is **provided**, the signal is **scheduled** — it waits for the market to reach `priceOpen` (a limit/grid-style entry). It is auto-cancelled after `CC_SCHEDULE_AWAIT_MINUTES`, or if SL is hit before activation.
- If `priceOpen` is **omitted**, the signal opens **immediately at the current VWAP**.
Direction rules (validated automatically): for `long`, `priceTakeProfit > priceOpen` and `priceStopLoss < priceOpen`; for `short`, the inverse.
### 4.5 Supported order types
With per-entry PNL, peak profit, and max drawdown tracking:
- Market / Limit entries
- TP / SL / OCO exits
- Grid (auto-cancel when entry condition or SL fires before activation)
- Partial profit / loss levels
- Trailing take-profit / trailing stop-loss
- Breakeven protection
- Stop-limit entries
- Dollar-cost averaging (DCA via `commitAverageBuy`)
- Time-attack / infinite-hold (`minuteEstimatedTime: Infinity`)
---
## 5. The execution model
### 5.1 Async context propagation
`backtest-kit` uses Node's `AsyncLocalStorage` to propagate two contexts through the entire async call tree without threading parameters:
- **ExecutionContextService** — `{ symbol, when: Date, backtest: boolean }`. The clock.
- **MethodContextService** — `{ strategyName, exchangeName, frameName }`. The identity.
Almost every public function (`getCandles`, `getAveragePrice`, `commitPartialProfit`, `getPositionPnlPercent`, …) reads these contexts internally. They **throw if called outside an active context** — i.e. you can only call them from inside `getSignal` or a strategy callback (`onActivePing`, `onClose`, …), not at module top-level. Use `hasTradeContext()` to test for an active context before calling.
`getMode()` returns `"backtest" | "live"`; `getDate()` returns the current `when`; `getSymbol()` returns the symbol; `getRuntimeInfo()` returns the full `{ symbol, context, backtest, range, currentPrice, info, when }` snapshot ([§28](#28-reflection--introspection)).
### 5.2 VWAP pricing
The engine's "current price" is the **VWAP** of the last `CC_AVG_PRICE_CANDLES_COUNT` (default 5) one-minute candles:
```
TypicalPrice = (high + low + close) / 3
VWAP = Σ(TypicalPrice × volume) / Σ(volume)
```
If total volume is zero, the engine falls back to the simple average of close prices. The same VWAP is used in backtest and live so results are comparable. Obtain it with `getAveragePrice(symbol)`.
### 5.3 Candle timestamp convention
A candle's `timestamp` is its **openTime**, never its closeTime. Close time = `timestamp + stepMs` where `stepMs` is the interval duration (e.g. 60000 for `"1m"`).
All timestamps are aligned **down** to the interval boundary (e.g. for `15m`: `00:17 → 00:15`, `00:44 → 00:30`), in **UTC** (Unix epoch). For a `4h` interval the boundaries are `00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC` — they will look "uneven" if printed in a non-UTC-multiple local zone; use `toISOString()`/`toUTCString()` in callbacks to see true aligned boundaries.
### 5.4 Look-ahead bias protection
The fetch functions ([§11](#11-exchange-data-api--candle-math)) all compute timestamps relative to the current virtual `when` and exclude the in-progress (pending) candle:
- `getCandles` returns the half-open range `[since, alignedWhen)` — the candle *at* `alignedWhen` is **not** returned (it is still open).
- `getNextCandles` (backtest only) returns `[alignedWhen, …)` going forward — throws in live mode to prevent look-ahead.
- `getRawCandles` supports flexible `(limit, sDate, eDate)` combinations, all validated so `eDate <= when`.
It is therefore structurally impossible for a strategy to observe data from after its current tick.
### 5.5 Interval throttling
`getSignal` is throttled to the strategy `interval` (default `"1m"`; one of `"1m" | "3m" | "5m" | "15m" | "30m" | "1h"`). Even if the engine ticks every minute, `getSignal` is invoked at most once per interval window. Ping callbacks (`onActivePing`, `onSchedulePing`, `onIdlePing`) fire **every minute regardless of interval**, so position management can be finer-grained than signal generation.
---
## 6. Schema registration reference
All `addXxxSchema` functions register a configuration object under a string name. Each has a matching `overrideXxxSchema` (replace an existing registration), `getXxxSchema` (retrieve the raw schema), and `listXxxSchema` (list registered names). Registration is idempotent on the name.
| Domain | Add | Override | Get | List |
| --- | --- | --- | --- | --- |
| Exchange | `addExchangeSchema` | `overrideExchangeSchema` | `getExchangeSchema` | `listExchangeSchema` |
| Strategy | `addStrategySchema` | `overrideStrategySchema` | `getStrategySchema` | `listStrategySchema` |
| Frame | `addFrameSchema` | `overrideFrameSchema` | `getFrameSchema` | `listFrameSchema` |
| Risk | `addRiskSchema` | `overrideRiskSchema` | `getRiskSchema` | `listRiskSchema` |
| Sizing | `addSizingSchema` | `overrideSizingSchema` | `getSizingSchema` | `listSizingSchema` |
| Walker | `addWalkerSchema` | `overrideWalkerSchema` | `getWalkerSchema` | `listWalkerSchema` |
| Action | `addActionSchema` | `overrideActionSchema` | `getActionSchema` | — |
### 6.1 Exchange schema — `addExchangeSchema(schema: IExchangeSchema)`
The data source. Only `exchangeName` and `getCandles` are required; everything else has defaults.
```typescript
interface IExchangeSchema {
exchangeName: ExchangeName; // unique id
note?: string;
// REQUIRED — fetch OHLCV; backtest flag tells you whether you may use sliced historical data
getCandles: (symbol: string, interval: CandleInterval, since: Date, limit: number, backtest: boolean)
=> Promise<IPublicCandleData[]>;
// OPTIONAL — default Binance precision (2 dp price, 8 dp quantity) if omitted
formatPrice?: (symbol: string, price: number, backtest: boolean) => Promise<string>;
formatQuantity?: (symbol: string, quantity: number, backtest: boolean) => Promise<string>;
// OPTIONAL — throw-if-called if omitted
getOrderBook?: (symbol: string, depth: number, from: Date, to: Date, backtest: boolean) => Promise<IOrderBookData>;
getAggregatedTrades?: (symbol: string, from: Date, to: Date, backtest: boolean) => Promise<IAggregatedTradeData[]>;
callbacks?: Partial<{
onCandleData: (symbol, interval, since, limit, data) => void | Promise<void>;
}>;
}
```
`CandleInterval = "1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h" | "1d"`.
**Adapter contract for `getCandles`** (enforced by validation):
- First returned candle's `timestamp` must equal the aligned `since`.
- Exactly `limit` candles must be returned.
- Timestamps must be sequential: `since + i * stepMs` for `i = 0 … limit-1`.
Data types:
```typescript
interface IPublicCandleData { timestamp; open; high; low; close; volume; } // all number | undefined
interface ICandleData { timestamp: number; open; high; low; close; volume: number; } // all required
interface IBidData { price: string; quantity: string; }
interface IOrderBookData { symbol: string; bids: IBidData[]; asks: IBidData[]; }
interface IAggregatedTradeData { id: string; price: number; qty: number; timestamp: number; isBuyerMaker: boolean; }
```
`formatPrice`/`formatQuantity` may return synchronously or as a `Promise` — both are accepted.
### 6.2 Strategy schema — `addStrategySchema(schema: IStrategySchema)`
```typescript
interface IStrategySchema {
strategyName: StrategyName; // unique id
note?: string;
interval?: SignalInterval; // throttle for getSignal; default "1m"
// Returns a signal DTO or null. `when` and `currentPrice` are supplied by the engine.
getSignal?: (symbol: string, when: Date, currentPrice: number) => Promise<ISignalDto | null>;
callbacks?: Partial<IStrategyCallbacks>;
riskName?: RiskName; // single risk profile
riskList?: RiskName[]; // multiple risk profiles (all must pass)
actions?: ActionName[]; // attached action handlers (see §22)
info?: RuntimeData; // arbitrary Record<string, unknown> surfaced in getRuntimeInfo()
}
type SignalInterval = "1m" | "3m" | "5m" | "15m" | "30m" | "1h";
type RuntimeData = Record<string, unknown>;
```
**`ISignalDto`** — what `getSignal` returns:
```typescript
interface ISignalDto {
id?: string; // auto-generated UUID v4 if omitted
symbol?: string;
position: "long" | "short";
note?: string;
priceOpen?: number; // provided → scheduled entry; omitted → immediate at VWAP
priceTakeProfit: number; // long: > priceOpen ; short: < priceOpen
priceStopLoss: number; // long: < priceOpen ; short: > priceOpen
minuteEstimatedTime?: number; // Infinity = no timeout; default CC_MAX_SIGNAL_LIFETIME_MINUTES (1440)
cost?: number; // entry cost in USD; default CC_POSITION_ENTRY_COST (100)
}
```
**`IStrategyCallbacks`** — all optional, all receive `when: Date` and `backtest: boolean`. The argument order in `v13.6.0` is shown below (note: `when` precedes `backtest`, and partial callbacks pass the percent *before* `currentPrice`):
```typescript
interface IStrategyCallbacks {
onTick: (symbol, result: IStrategyTickResult, currentPrice, when, backtest) => void | Promise<void>;
onOpen: (symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onActive: (symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onIdle: (symbol, currentPrice, when, backtest) => void | Promise<void>;
onClose: (symbol, data: IPublicSignalRow, priceClose, when, backtest) => void | Promise<void>;
onSchedule:(symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onCancel: (symbol, data: IPublicSignalRow, currentPrice, when, backtest) => void | Promise<void>;
onWrite: (symbol, data: ISignalRow | null, currentPrice, when, backtest) => void;
onPartialProfit: (symbol, data, revenuePercent, currentPrice, when, backtest) => void | Promise<void>;
onPartialLoss: (symbol, data, lossPercent, currentPrice, when, backtest) => void | Promise<void>;
onBreakeven: (symbol, data, currentPrice, when, backtest) => void | Promise<void>;
// Fire EVERY minute regardless of `interval` — the right place for dynamic management:
onSchedulePing: (symbol, data, currentPrice, when, backtest) => void | Promise<void>;
onActivePing: (symbol, data, currentPrice, when, backtest) => void | Promise<void>;
}
```
Use `onActivePing` to call `commitAverageBuy` / `commitPartialProfit` / `commitTrailingStop` / `commitBreakeven` against the live position ([§9](#9-commit-functions)).
### 6.3 Frame schema — `addFrameSchema(schema: IFrameSchema)`
Defines the backtest period. (Live mode ignores frames — it uses the wall clock.)
```typescript
interface IFrameSchema {
frameName: FrameName; // unique id
note?: string;
interval?: FrameInterval; // tick granularity; default "1m"
startDate: Date; // inclusive
endDate: Date; // inclusive
callbacks?: Partial<{
onTimeframe: (timeframe: Date[], startDate: Date, endDate: Date, interval: FrameInterval) => void | Promise<void>;
}>;
}
type FrameInterval =
"1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h" | "12h" | "1d";
```
The number of generated ticks corresponds to `(endDate - startDate) / interval`. For a 1-day frame at `1m` granularity that is ~1440 ticks. The `interval` here is the engine's *step size*, independent of any `getCandles` interval you request inside `getSignal`.
### 6.4 Risk schema — see [§16](#16-risk-management)
### 6.5 Sizing schema — see [§15](#15-position-sizing)
### 6.6 Walker schema — see [§13.3](#133-walker)
### 6.7 Action schema — see [§22](#22-actions-pluggable-event-handlers)
---
## 7. Signal lifecycle & tick results
### 7.1 State machine
A signal moves through a type-safe state machine. Each tick yields exactly one `IStrategyTickResult` discriminated on `action`:
```
idle ──getSignal──▶ scheduled ──price reaches priceOpen──▶ opened ──▶ active ──▶ closed
│ │ ▲
│ └── timeout / SL-before-entry ─▶ cancelled │
└── getSignal (no priceOpen) ───────────────────────▶ opened ────────────────────┘
```
- `idle` — no active or scheduled signal this tick.
- `scheduled` — a limit/grid signal was just created and is waiting for `priceOpen`.
- `waiting` — emitted on subsequent ticks while a scheduled signal is still waiting (distinct from the one-time `scheduled`).
- `opened` — a position just became active (either immediate, or a scheduled signal that activated).
- `active` — an open position is being monitored (carries `percentTp`, `percentSl`, `pnl`).
- `closed` — position exited (carries `closeReason`, `closeTimestamp`, `pnl`).
- `cancelled` — a scheduled signal never activated (carries `reason`).
### 7.2 Tick result union
```typescript
type IStrategyTickResult =
| IStrategyTickResultIdle // { action: "idle"; signal: null; currentPrice; … }
| IStrategyTickResultScheduled // { action: "scheduled"; signal: IPublicSignalRow; … }
| IStrategyTickResultWaiting // { action: "waiting"; signal; percentTp:0; percentSl:0; pnl; … }
| IStrategyTickResultOpened // { action: "opened"; signal; currentPrice; … }
| IStrategyTickResultActive // { action: "active"; signal; percentTp; percentSl; pnl; … }
| IStrategyTickResultClosed // { action: "closed"; signal; closeReason; closeTimestamp; pnl; … }
| IStrategyTickResultCancelled; // { action: "cancelled"; signal; reason; closeTimestamp; … }
type StrategyCloseReason = "time_expired" | "take_profit" | "stop_loss" | "closed";
type StrategyCancelReason = "timeout" | "price_reject" | "user";
```
Every variant carries `strategyName`, `exchangeName`, `frameName`, `symbol`, `currentPrice`, `backtest`, and `createdAt`. Use a type guard on `action` for type-safe field access:
```typescript
for await (const result of Backtest.run("BTCUSDT", config)) {
if (result.action === "closed") {
console.log(result.closeReason, result.pnl.pnlPercentage);
}
}
```
`Backtest.run` yields `opened | scheduled | active | closed | cancelled` (an `active` result only appears when the frame is exhausted while a `minuteEstimatedTime: Infinity` position is still open). `Live.run` yields the full set including `idle`.
### 7.3 PNL object — `IStrategyPnL`
```typescript
interface IStrategyPnL {
pnlPercentage: number; // e.g. 1.5 = +1.5%
priceOpen: number; // entry adjusted for slippage + fees
priceClose: number; // exit adjusted for slippage + fees
pnlCost: number; // absolute USD P/L = pnlPercentage/100 * pnlEntries
pnlEntries: number; // total invested capital in USD (sum of all entry costs)
}
```
### 7.4 `IPublicSignalRow` — the signal object surfaced everywhere
`ISignalDto` (your input) is augmented into `ISignalRow` (internal) and exposed as `IPublicSignalRow` in events, callbacks, and analytics. Key public fields beyond the DTO:
```typescript
interface IPublicSignalRow extends ISignalRow {
cost: number; // cost of the initial entry (not DCA)
originalPriceOpen: number; // entry at creation (unchanged by averaging)
originalPriceStopLoss: number; // SL at creation (unchanged by trailing)
originalPriceTakeProfit: number;// TP at creation (unchanged by trailing)
partialExecuted: number; // 0–100, sum of all partial-close percentages
totalEntries: number; // _entry.length (1 = no DCA)
totalPartials: number; // _partial.length (0 = no partial closes)
pnl: IStrategyPnL; // unrealized PNL at emission
peakProfit: IStrategyPnL; // best favorable excursion so far
maxDrawdown: IStrategyPnL; // worst adverse excursion so far
}
```
Internal `_`-prefixed fields (also present, useful when persisting/inspecting): `_entry[]` (DCA history `{ price, cost, timestamp }`), `_partial[]` (partial-close history `{ type, percent, currentPrice, costBasisAtClose, entryCountAtClose, timestamp }`), `_trailingPriceStopLoss`, `_trailingPriceTakeProfit`, `_peak`, `_fall`, `pendingAt`, `scheduledAt`.
### 7.5 Signal validation rules
Every signal is validated automatically before it is opened/scheduled. Failures throw with a detailed message (surfaced via `listenError` / `listenValidation`). The exported validators (`validateSignal`, `validateCommonSignal`, `validatePendingSignal`, `validateScheduledSignal`) implement these rules and can also be called standalone.
**Common rules (`validateCommonSignal`)** — applied to every signal:
- `priceOpen`, `priceTakeProfit`, `priceStopLoss` must each be a **finite, positive number**.
- Direction correctness:
- LONG: `priceTakeProfit > priceOpen` **and** `priceStopLoss < priceOpen`.
- SHORT: `priceTakeProfit < priceOpen` **and** `priceStopLoss > priceOpen`.
- Distance floors/ceilings (when configured):
- TP distance ≥ `CC_MIN_TAKEPROFIT_DISTANCE_PERCENT` (default 0.5%) — must exceed slippage+fees so a trade can be net-profitable.
- SL distance ≥ `CC_MIN_STOPLOSS_DISTANCE_PERCENT` (0.5%) — avoids instant stop-out on noise.
- SL distance ≤ `CC_MAX_STOPLOSS_DISTANCE_PERCENT` (20%) — caps catastrophic single-signal loss.
**Immediate (pending) signals (`validatePendingSignal`)** — when `priceOpen` is omitted and the position opens now at `currentPrice`:
- LONG: rejects if `currentPrice <= priceStopLoss` (would instantly stop) or `currentPrice >= priceTakeProfit` (would instantly take profit).
- SHORT: rejects if `currentPrice >= priceStopLoss` or `currentPrice <= priceTakeProfit`.
**Scheduled signals (`validateScheduledSignal`)** — when `priceOpen` is provided:
- `priceOpen` must lie strictly **between** SL and TP so activation would not immediately close the position:
- LONG: `priceStopLoss < priceOpen < priceTakeProfit`.
- SHORT: `priceTakeProfit < priceOpen < priceStopLoss`.
```typescript
// ✅ valid LONG
{ position: "long", priceOpen: 50000, priceTakeProfit: 51000, priceStopLoss: 49000 }
// ❌ invalid LONG — throws (TP below open, SL above open)
{ position: "long", priceOpen: 50000, priceTakeProfit: 49000, priceStopLoss: 51000 }
// ✅ valid SHORT
{ position: "short", priceOpen: 50000, priceTakeProfit: 49000, priceStopLoss: 51000 }
```
### 7.6 Candle data validation
`validateCandles` (and the internal fetch path) reject incomplete/anomalous candles from the data source:
- Every OHLC value must be positive and finite (catches Binance incomplete-candle ~0 prices).
- With ≥ `CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN` (default 5) candles a **median** reference price is used; below that, a simple average — then any candle whose price is more than `CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR` (default 1000×) below the reference is rejected as an anomaly.
- First-timestamp + exact-count + sequential-timestamp checks (the adapter contract from [§6.1](#61-exchange-schema--addexchangeschemaschema-iexchangeschema)) are enforced on `getCandles`, `getNextCandles`, `getRawCandles`, and the cache layer.
`getCandles` retries up to `CC_GET_CANDLES_RETRY_COUNT` (3) times with `CC_GET_CANDLES_RETRY_DELAY_MS` (5000 ms) between attempts; requests larger than `CC_MAX_CANDLES_PER_REQUEST` (1000) are paginated.
---
## 8. Strategy context functions
These are called from inside `getSignal` or a strategy callback. They read the execution + method context automatically (no `strategyName`/`exchangeName` arguments) and throw if no context is active. Import them as named functions from `backtest-kit`.
### 8.1 Signal & position queries
| Function | Signature | Returns |
| --- | --- | --- |
| `getPendingSignal` | `(symbol) => Promise<IPublicSignalRow \| null>` | The active open position, or `null`. |
| `getScheduledSignal` | `(symbol) => Promise<IPublicSignalRow \| null>` | The waiting scheduled signal, or `null`. |
| `hasNoPendingSignal` | `(symbol) => Promise<boolean>` | `true` if no open position. |
| `hasNoScheduledSignal` | `(symbol) => Promise<boolean>` | `true` if no scheduled signal. |
| `getBreakeven` | `(symbol, currentPrice) => Promise<boolean>` | `true` if price has cleared the breakeven threshold (covers fees+slippage). |
| `getStrategyStatus` | `(symbol) => Promise<StrategyStatus>` | Deferred-state snapshot (commit queue, created/closed/cancelled/activated signal, pendingSignalId). |
| `getTotalPercentClosed` | `(symbol) => Promise<number>` | % of position still held (100 = full, 0 = fully closed), DCA-aware. |
| `getTotalCostClosed` | `(symbol) => Promise<number>` | USD cost basis still held, DCA-aware. |
| `getLatestSignal` | `(symbol) => Promise<IPublicSignalRow \| null>` | Most recent signal (pending or closed) — useful for cooldown logic. |
| `getMinutesSinceLatestSignalCreated` | `(symbol) => Promise<number \| null>` | Whole minutes since the latest signal was created. |
```typescript
// Guard pattern inside getSignal:
addStrategySchema({
strategyName: "guarded",
getSignal: async (symbol, when, currentPrice) => {
if (!(await hasNoPendingSignal(symbol))) return null; // one position at a time
const minutes = await getMinutesSinceLatestSignalCreated(symbol);
if (minutes !== null && minutes < 240) return null; // 4h cooldown after last signal
return { position: "long", priceTakeProfit: currentPrice * 1.03, priceStopLoss: currentPrice * 0.99 };
},
});
```
### 8.2 Meta functions
| Function | Signature | Notes |
| --- | --- | --- |
| `hasTradeContext` | `() => boolean` | `true` if both execution + method contexts are active. |
| `getDate` | `() => Promise<Date>` | Current virtual (backtest) or real (live) time. |
| `getTimestamp` | `() => Promise<number>` | Same as `getDate().getTime()`, via the time-meta service. |
| `getMode` | `() => Promise<"backtest" \| "live">` | |
| `getSymbol` | `() => Promise<string>` | Current symbol. |
| `getContext` | `() => Promise<{ strategyName; exchangeName; frameName }>` | Method context. |
| `getRuntimeInfo` | `<Data>() => Promise<IRuntimeInfo<Data>>` | Full snapshot: `{ symbol, context, backtest, range, currentPrice, info, when }`. |
### 8.3 `createSignalState` — typed per-signal accumulator (recommended)
Returns a bound `[getState, setState]` tuple scoped to a bucket and an active signal. Both resolve the signal and backtest flag from context — no `signalId` argument. Ideal for capitulation logic that accumulates per-trade metrics across `onActivePing` ticks.
```typescript
import { createSignalState } from "backtest-kit";
const [getTradeState, setTradeState] = createSignalState({
bucketName: "trade",
initialValue: { peakPercent: 0, minutesOpen: 0 },
});
// inside onActivePing:
await setTradeState((s) => ({
peakPercent: Math.max(s.peakPercent, currentUnrealisedPercent),
minutesOpen: s.minutesOpen + 1,
}));
const { peakPercent, minutesOpen } = await getTradeState();
if (minutesOpen >= 15 && peakPercent < 0.3) await commitClosePending(symbol); // capitulate
```
> `getSignalState(symbol, { bucketName, initialValue })` and `setSignalState(symbol, dispatch, { bucketName, initialValue })` are the lower-level equivalents and are **deprecated** in favour of `createSignalState`.
---
## 9. Commit functions
The **position-mutation API**. Called from `onActivePing` (or other callbacks) with `await`. All read context automatically. Mutations are queued and applied transactionally; in live mode each is intercepted by the `Broker` adapter *before* internal state changes ([§17](#17-broker-transactional-live-orders)).
### 9.1 Lifecycle commits
| Function | Signature | Effect |
| --- | --- | --- |
| `commitCreateSignal` | `(symbol, currentPrice, dto: ISignalDto) => Promise<void>` | Queue a user-supplied signal for the next tick instead of `getSignal`. |
| `commitClosePending` | `(symbol, payload?: Partial<CommitPayload>) => Promise<void>` | Close the open position now (`closeReason: "closed"`). |
| `commitCancelScheduled` | `(symbol, payload?: Partial<CommitPayload>) => Promise<void>` | Cancel the waiting scheduled signal (`reason: "user"`). |
| `commitActivateScheduled` | `(symbol, payload?: Partial<CommitPayload>) => Promise<void>` | Force-activate the scheduled signal at the current price without waiting for `priceOpen`. |
| `commitSignalNotify` | `(symbol, payload: SignalNotificationPayload) => Promise<void>` | Emit a user notification tied to the signal. |
`CommitPayload = { id: string; note: string }` (both optional via `Partial`).
### 9.2 DCA (dollar-cost averaging)
```typescript
commitAverageBuy(symbol: string, cost?: number): Promise<boolean>
```
Adds a new entry to the open position. `cost` defaults to `CC_POSITION_ENTRY_COST` ($100). **Default acceptance rule:** the entry is accepted only when `currentPrice` beats the all-time extreme since entry —
- LONG: accepted only when `currentPrice` is a new low (below every prior entry price);
- SHORT: accepted only when `currentPrice` is a new high (above every prior entry price).
This prevents averaging *up* (into a losing direction the wrong way). When rejected it returns `false` silently. Set `CC_ENABLE_DCA_EVERYWHERE: true` to relax the rule to "any price still beyond `priceOpen`" rather than a new extreme. Each accepted entry shifts the effective `priceOpen` (harmonic/cost-basis mean — see [§12](#12-pnl-dca--effective-price-math)), which in turn changes whether the next `commitAverageBuy` is accepted.
### 9.3 Partial closes
| Function | Signature | Effect |
| --- | --- | --- |
| `commitPartialProfit` | `(symbol, percentToClose: number) => Promise<boolean>` | Close `percentToClose` % (0–100) of the position at profit. Throws if price is not in profit direction. |
| `commitPartialLoss` | `(symbol, percentToClose: number) => Promise<boolean>` | Close `percentToClose` % at loss. |
| `commitPartialProfitCost` | `(symbol, dollarAmount: number) => Promise<boolean>` | Close a USD `dollarAmount` worth at profit. |
| `commitPartialLossCost` | `(symbol, dollarAmount: number) => Promise<boolean>` | Close a USD `dollarAmount` worth at loss. |
Returns `false` (skips) if closing would exceed 100% total closed, or if the precondition fails. By default a partial-profit only succeeds when price is moving toward TP and a partial-loss only when moving toward SL; set `CC_ENABLE_PPPL_EVERYWHERE: true` to allow mixing.
```typescript
addStrategySchema({
strategyName: "scale-out",
getSignal,
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
const pct = await getPositionPnlPercent(symbol, currentPrice);
if (pct !== null && pct >= 3) await commitPartialProfit(symbol, 33);
if (pct !== null && pct >= 6) await commitPartialProfit(symbol, 33);
},
},
});
```
### 9.4 Trailing & breakeven
| Function | Signature | Effect |
| --- | --- | --- |
| `commitTrailingStop` | `(symbol, percentShift: number, currentPrice: number) => Promise<boolean>` | Move SL to a trailing distance `percentShift` % behind `currentPrice` (ratchets one way only). |
| `commitTrailingTake` | `(symbol, percentShift: number, currentPrice: number) => Promise<boolean>` | Adjust TP by `percentShift` % relative to `currentPrice`. |
| `commitTrailingStopCost` | `(symbol, newStopLossPrice: number) => Promise<boolean>` | Set the trailing SL to an absolute price. |
| `commitTrailingTakeCost` | `(symbol, newTakeProfitPrice: number) => Promise<boolean>` | Set the trailing TP to an absolute price. |
| `commitBreakeven` | `(symbol) => Promise<boolean>` | Move SL to entry (breakeven) once `getBreakeven(symbol, currentPrice)` would return `true`. |
The trailing SL never moves against the position (for LONG it only moves up, for SHORT only down). The original SL/TP are preserved in `originalPriceStopLoss`/`originalPriceTakeProfit`; the trailing values override them for exit evaluation. Set `CC_ENABLE_TRAILING_EVERYWHERE: true` to activate trailing without absorption conditions.
```typescript
callbacks: {
onActivePing: async (symbol, data, currentPrice) => {
if (await getBreakeven(symbol, currentPrice)) await commitBreakeven(symbol);
await commitTrailingStop(symbol, 1.0, currentPrice); // 1% trailing stop
},
}
```
---
## 10. Position analytics functions
A large family of read-only `getPosition*` functions describing the current open position. All read context automatically, take `(symbol)` (a few also take `currentPrice`), and return `null` when there is no pending signal. They are also available as methods on `Backtest`/`Live` (with explicit `(symbol, [currentPrice,] context)`), which is how you query a position from *outside* a strategy callback.
### 10.1 Composition & cost basis
| Function | Returns | Meaning |
| --- | --- | --- |
| `getPositionEffectivePrice` | `number \| null` | Weighted-average (cost-basis) entry price across all DCA entries. |
| `getPositionInvestedCount` | `number \| null` | Total base-asset units held (sum across DCA entries). |
| `getPositionInvestedCost` | `number \| null` | Total USD cost invested (sum of entry costs). |
| `getPositionEntries` | `Array<{ price; cost; timestamp }> \| null` | All entries; `[0]` is the original `priceOpen`. |
| `getPositionLevels` | `number[] \| null` | Just the entry prices; single-element `[priceOpen]` if no DCA. |
| `getPositionPartials` | `Array<{ type:"profit"\|"loss"; percent; currentPrice; costBasisAtClose; entryCountAtClose; timestamp }> \| null` | Partial-close history. |
### 10.2 Live PNL
| Function | Signature | Returns |
| --- | --- | --- |
| `getPositionPnlPercent` | `(symbol, currentPrice)` | Unrealized PNL % vs effective entry (fees/slippage/partials aware). |
| `getPositionPnlCost` | `(symbol, currentPrice)` | Unrealized PNL in USD. |
### 10.3 Timing
| Function | Returns | Meaning |
| --- | --- | --- |
| `getPositionEstimateMinutes` | `number \| null` | Original `minuteEstimatedTime`. |
| `getPositionCountdownMinutes` | `number \| null` | Remaining minutes before `time_expired` (clamped to 0). |
| `getPositionActiveMinutes` | `number \| null` | Minutes the position has been open. |
| `getPositionWaitingMinutes` | `number \| null` | Minutes a scheduled signal has been waiting for activation. |
### 10.4 Peak profit (best favorable excursion)
| Function | Returns |
| --- | --- |
| `getPositionHighestProfitPrice` | Best price seen in the profit direction. |
| `getPositionHighestProfitTimestamp` | When that peak occurred. |
| `getPositionHighestPnlPercentage` | Peak unrealized PNL %. |
| `getPositionHighestPnlCost` | Peak unrealized PNL in USD. |
| `getPositionHighestProfitMinutes` | Minutes from open to the peak. |
| `getPositionHighestProfitBreakeven` | Whether the peak ever cleared the breakeven threshold. |
### 10.5 Max drawdown (worst adverse excursion)
| Function | Returns |
| --- | --- |
| `getPositionDrawdownMinutes` | Minutes spent below the effective entry. |
| `getPositionMaxDrawdownPrice` | Worst price seen in the loss direction. |
| `getPositionMaxDrawdownTimestamp` | When that trough occurred. |
| `getPositionMaxDrawdownMinutes` | Minutes from open to the trough. |
| `getPositionMaxDrawdownPnlPercentage` | Worst unrealized PNL %. |
| `getPositionMaxDrawdownPnlCost` | Worst unrealized PNL in USD. |
### 10.6 Cross-section distances (peak ↔ trough analysis)
| Function | Meaning |
| --- | --- |
| `getPositionHighestMaxDrawdownPnlPercentage` / `…PnlCost` | The worst drawdown that occurred *after* the highest profit. |
| `getPositionHighestProfitDistancePnlPercentage` / `…PnlCost` | Distance between peak profit and the current/closing point. |
| `getMaxDrawdownDistancePnlPercentage` / `getMaxDrawdownDistancePnlCost` | Distance from the max-drawdown point. |
### 10.7 Overlap (DCA / partial spacing diagnostics)
| Function | Meaning |
| --- | --- |
| `getPositionEntryOverlap` | `(symbol, currentPrice, ladder?) => Promise<boolean>` — `true` if `currentPrice` falls within the spacing band of an existing DCA entry. |
| `getPositionPartialOverlap` | `(symbol, currentPrice, ladder?) => Promise<boolean>` — same, for partial-close prices. |
`ladder` is an `IPositionOverlapLadder` (`{ upperPercent, lowerPercent }`, default `POSITION_OVERLAP_LADDER_DEFAULT`). This is the **DCA-ladder spacing guard**: before adding a rung, check `getPositionEntryOverlap` and skip if it returns `true`, so entries are spaced at least `lowerPercent`/`upperPercent` apart (see the ladder recipe in [§22.5](#225-strategy-recipes) and the Mar/Apr 2026 examples in [§34](#34-strategy-examples-reference-implementations)).
---
## 11. Exchange data API & candle math
These functions fetch market data from the registered exchange, always relative to the current virtual `when`, always look-ahead-safe. Import them from `backtest-kit`; call from inside a strategy/callback (active context required).
### 11.1 Functions
| Function | Signature | Notes |
| --- | --- | --- |
| `getCandles` | `(symbol, interval, limit) => Promise<ICandleData[]>` | `limit` candles **backwards** from aligned `when`. Range `[since, alignedWhen)`. |
| `getNextCandles` | `(symbol, interval, limit) => Promise<ICandleData[]>` | `limit` candles **forwards** from aligned `when`. **Backtest only** — throws in live (look-ahead). Range `[alignedWhen, …)`. |
| `getRawCandles` | `(symbol, interval, limit?, sDate?, eDate?) => Promise<ICandleData[]>` | Flexible date/limit combos (see below). |
| `getAveragePrice` | `(symbol) => Promise<number>` | VWAP of last `CC_AVG_PRICE_CANDLES_COUNT` 1-minute candles. |
| `getClosePrice` | `(symbol, interval) => Promise<number>` | Close of the last completed candle for `interval`. |
| `getOrderBook` | `(symbol, depth?) => Promise<IOrderBookData>` | Depth defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`. |
| `getAggregatedTrades` | `(symbol, limit?) => Promise<IAggregatedTradeData[]>` | No `limit` → one `CC_AGGREGATED_TRADES_MAX_MINUTES` window; with `limit` → paginates backwards then slices to most-recent `limit`. |
| `formatPrice` | `(symbol, price) => Promise<string>` | Exchange precision. |
| `formatQuantity` | `(symbol, quantity) => Promise<string>` | Exchange precision. |
| `hasTradeContext` | `() => boolean` | Guard before calling any of the above. |
### 11.2 `getRawCandles` parameter combinations
All combinations validate `eDate <= when` (look-ahead protection). `sDate`/`eDate` are epoch milliseconds.
1. `(limit)` — `since = alignedWhen - limit*stepMs`, range `[since, alignedWhen)`.
2. `(limit, sDate)` — `since = align(sDate)`, `limit` candles forward, range `[since, since + limit*stepMs)`.
3. `(limit, undefined, eDate)` — `since = align(eDate) - limit*stepMs`, range `[since, eDate)` (**eDate exclusive**).
4. `(undefined, sDate, eDate)` — `limit` computed from range, **sDate inclusive, eDate exclusive**, range `[sDate, eDate)`.
5. `(limit, sDate, eDate)` — `since = align(sDate)`, `limit` candles, sDate inclusive.
### 11.3 Timestamp alignment math (worked example)
```
// 15-minute interval, when = 00:12:00
stepMs = 15 * 60000 = 900000
alignedWhen = floor(when / stepMs) * stepMs = 00:00:00
// getCandles("BTCUSDT","15m",4):
since = alignedWhen - 4*stepMs = 23:00:00 (prev day)
// returns timestamps: 23:00, 23:15, 23:30, 23:45 — the 00:00 candle is EXCLUDED (still open)
```
**Why exclude the pending candle:** at `when = 00:12`, the `00:00` candle covers `[00:00, 00:15)` and is incomplete; its OHLCV would distort indicators. Only fully-closed candles are returned. Validation (first-timestamp + count) is applied uniformly across `getCandles`, `getNextCandles`, `getRawCandles`, and the cache layer.
### 11.4 Order book & aggregated trades windows