backtest-kit 3.0.18 → 3.1.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 +49 -1
- package/build/index.cjs +327 -126
- package/build/index.mjs +327 -127
- package/package.json +1 -1
- package/types.d.ts +59 -1
package/README.md
CHANGED
|
@@ -441,6 +441,30 @@ Unlike cloud-based platforms, backtest-kit runs entirely in your environment. Yo
|
|
|
441
441
|
|
|
442
442
|
The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
|
|
443
443
|
|
|
444
|
+
|
|
445
|
+
### @backtest-kit/cli
|
|
446
|
+
|
|
447
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** 📟
|
|
448
|
+
|
|
449
|
+
The **@backtest-kit/cli** package is a zero-boilerplate CLI runner for backtest-kit strategies. Point it at your strategy file and run backtests, paper trading, or live bots — no infrastructure code required.
|
|
450
|
+
|
|
451
|
+
#### Key Features
|
|
452
|
+
- 🚀 **Zero Config**: Run a backtest with one command — no setup code needed
|
|
453
|
+
- 🔄 **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
|
|
454
|
+
- 💾 **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
|
|
455
|
+
- 🌐 **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
|
|
456
|
+
- 📬 **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
|
|
457
|
+
- 🗂️ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
|
|
458
|
+
|
|
459
|
+
#### Use Case
|
|
460
|
+
The fastest way to run any backtest-kit strategy from the command line. Instead of writing boilerplate for storage, notifications, candle caching, and signal logging, add one dependency and wire up your `package.json` scripts. Works equally well for a single-strategy project or a monorepo with dozens of strategies in separate subdirectories.
|
|
461
|
+
|
|
462
|
+
#### Get Started
|
|
463
|
+
```bash
|
|
464
|
+
npx -y @backtest-kit/cli --init
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
|
|
444
468
|
### @backtest-kit/pinets
|
|
445
469
|
|
|
446
470
|
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** 📜
|
|
@@ -531,9 +555,32 @@ npm install @backtest-kit/signals backtest-kit
|
|
|
531
555
|
```
|
|
532
556
|
|
|
533
557
|
|
|
558
|
+
|
|
559
|
+
### @backtest-kit/graph
|
|
560
|
+
|
|
561
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** 🔗
|
|
562
|
+
|
|
563
|
+
The **@backtest-kit/graph** package lets you compose backtest-kit computations as a typed directed acyclic graph (DAG). Define source nodes that fetch market data and output nodes that compute derived values — then resolve the whole graph in topological order with automatic parallelism.
|
|
564
|
+
|
|
565
|
+
#### Key Features
|
|
566
|
+
- 🔌 **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
|
|
567
|
+
- 🔒 **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
|
|
568
|
+
- 🧱 **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
|
|
569
|
+
- 💾 **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
|
|
570
|
+
- 🌐 **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
|
|
571
|
+
|
|
572
|
+
#### Use Case
|
|
573
|
+
Perfect for multi-timeframe strategies where multiple Pine Script or indicator computations must be combined. Instead of manually chaining async calls, define each computation as a node and let the graph resolve dependencies in parallel. Adding a new filter or timeframe requires no changes to the existing wiring.
|
|
574
|
+
|
|
575
|
+
#### Get Started
|
|
576
|
+
```bash
|
|
577
|
+
npm install @backtest-kit/graph backtest-kit
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
|
|
534
581
|
### @backtest-kit/sidekick
|
|
535
582
|
|
|
536
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)**
|
|
583
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** 🚀
|
|
537
584
|
|
|
538
585
|
The **@backtest-kit/sidekick** package is the easiest way to create a new Backtest Kit trading bot project. Like create-react-app, but for algorithmic trading.
|
|
539
586
|
|
|
@@ -555,6 +602,7 @@ cd my-trading-bot
|
|
|
555
602
|
npm start
|
|
556
603
|
```
|
|
557
604
|
|
|
605
|
+
|
|
558
606
|
## 🤖 Are you a robot?
|
|
559
607
|
|
|
560
608
|
**For language models**: Read extended description in [./LLMs.md](./LLMs.md)
|
package/build/index.cjs
CHANGED
|
@@ -442,6 +442,13 @@ const GLOBAL_CONFIG = {
|
|
|
442
442
|
* Default: 50 signals
|
|
443
443
|
*/
|
|
444
444
|
CC_MAX_SIGNALS: 50,
|
|
445
|
+
/**
|
|
446
|
+
* Enables mutex locking for candle fetching to prevent concurrent fetches of the same candles.
|
|
447
|
+
* This can help avoid redundant API calls and ensure data consistency when multiple processes/threads attempt to fetch candles simultaneously.
|
|
448
|
+
*
|
|
449
|
+
* Default: true (mutex locking enabled for candle fetching)
|
|
450
|
+
*/
|
|
451
|
+
CC_ENABLE_CANDLE_FETCH_MUTEX: true,
|
|
445
452
|
};
|
|
446
453
|
const DEFAULT_CONFIG = Object.freeze({ ...GLOBAL_CONFIG });
|
|
447
454
|
|
|
@@ -712,7 +719,7 @@ async function writeFileAtomic(file, data, options = {}) {
|
|
|
712
719
|
}
|
|
713
720
|
}
|
|
714
721
|
|
|
715
|
-
var _a$
|
|
722
|
+
var _a$3;
|
|
716
723
|
const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
|
|
717
724
|
// Calculate step in milliseconds for candle close time validation
|
|
718
725
|
const INTERVAL_MINUTES$8 = {
|
|
@@ -830,7 +837,7 @@ class PersistBase {
|
|
|
830
837
|
constructor(entityName, baseDir = path.join(process.cwd(), "logs/data")) {
|
|
831
838
|
this.entityName = entityName;
|
|
832
839
|
this.baseDir = baseDir;
|
|
833
|
-
this[_a$
|
|
840
|
+
this[_a$3] = functoolsKit.singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
|
|
834
841
|
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
|
|
835
842
|
entityName: this.entityName,
|
|
836
843
|
baseDir,
|
|
@@ -933,7 +940,7 @@ class PersistBase {
|
|
|
933
940
|
}
|
|
934
941
|
}
|
|
935
942
|
}
|
|
936
|
-
_a$
|
|
943
|
+
_a$3 = BASE_WAIT_FOR_INIT_SYMBOL;
|
|
937
944
|
// @ts-ignore
|
|
938
945
|
PersistBase = functoolsKit.makeExtendable(PersistBase);
|
|
939
946
|
/**
|
|
@@ -1940,6 +1947,69 @@ class PersistNotificationUtils {
|
|
|
1940
1947
|
*/
|
|
1941
1948
|
const PersistNotificationAdapter = new PersistNotificationUtils();
|
|
1942
1949
|
|
|
1950
|
+
var _a$2, _b$2;
|
|
1951
|
+
const BUSY_DELAY = 100;
|
|
1952
|
+
const SET_BUSY_SYMBOL = Symbol("setBusy");
|
|
1953
|
+
const GET_BUSY_SYMBOL = Symbol("getBusy");
|
|
1954
|
+
const ACQUIRE_LOCK_SYMBOL = Symbol("acquireLock");
|
|
1955
|
+
const RELEASE_LOCK_SYMBOL = Symbol("releaseLock");
|
|
1956
|
+
const ACQUIRE_LOCK_FN = async (self) => {
|
|
1957
|
+
while (self[GET_BUSY_SYMBOL]()) {
|
|
1958
|
+
await functoolsKit.sleep(BUSY_DELAY);
|
|
1959
|
+
}
|
|
1960
|
+
self[SET_BUSY_SYMBOL](true);
|
|
1961
|
+
};
|
|
1962
|
+
class Lock {
|
|
1963
|
+
constructor() {
|
|
1964
|
+
this._isBusy = 0;
|
|
1965
|
+
this[_a$2] = functoolsKit.queued(ACQUIRE_LOCK_FN);
|
|
1966
|
+
this[_b$2] = () => this[SET_BUSY_SYMBOL](false);
|
|
1967
|
+
this.acquireLock = async () => {
|
|
1968
|
+
await this[ACQUIRE_LOCK_SYMBOL](this);
|
|
1969
|
+
};
|
|
1970
|
+
this.releaseLock = async () => {
|
|
1971
|
+
await this[RELEASE_LOCK_SYMBOL]();
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
[SET_BUSY_SYMBOL](isBusy) {
|
|
1975
|
+
this._isBusy += isBusy ? 1 : -1;
|
|
1976
|
+
if (this._isBusy < 0) {
|
|
1977
|
+
throw new Error("Extra release in finally block");
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
[GET_BUSY_SYMBOL]() {
|
|
1981
|
+
return !!this._isBusy;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
_a$2 = ACQUIRE_LOCK_SYMBOL, _b$2 = RELEASE_LOCK_SYMBOL;
|
|
1985
|
+
|
|
1986
|
+
const METHOD_NAME_ACQUIRE_LOCK = "CandleUtils.acquireLock";
|
|
1987
|
+
const METHOD_NAME_RELEASE_LOCK = "CandleUtils.releaseLock";
|
|
1988
|
+
class CandleUtils {
|
|
1989
|
+
constructor() {
|
|
1990
|
+
this._lock = new Lock();
|
|
1991
|
+
this.acquireLock = async (source) => {
|
|
1992
|
+
bt.loggerService.info(METHOD_NAME_ACQUIRE_LOCK, {
|
|
1993
|
+
source,
|
|
1994
|
+
});
|
|
1995
|
+
if (!GLOBAL_CONFIG.CC_ENABLE_CANDLE_FETCH_MUTEX) {
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
return await this._lock.acquireLock();
|
|
1999
|
+
};
|
|
2000
|
+
this.releaseLock = async (source) => {
|
|
2001
|
+
bt.loggerService.info(METHOD_NAME_RELEASE_LOCK, {
|
|
2002
|
+
source,
|
|
2003
|
+
});
|
|
2004
|
+
if (!GLOBAL_CONFIG.CC_ENABLE_CANDLE_FETCH_MUTEX) {
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
return await this._lock.releaseLock();
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const Candle = new CandleUtils();
|
|
2012
|
+
|
|
1943
2013
|
const MS_PER_MINUTE$5 = 60000;
|
|
1944
2014
|
const INTERVAL_MINUTES$7 = {
|
|
1945
2015
|
"1m": 1,
|
|
@@ -2119,34 +2189,40 @@ const GET_CANDLES_FN = async (dto, since, self) => {
|
|
|
2119
2189
|
const step = INTERVAL_MINUTES$7[dto.interval];
|
|
2120
2190
|
const sinceTimestamp = since.getTime();
|
|
2121
2191
|
const untilTimestamp = sinceTimestamp + dto.limit * step * MS_PER_MINUTE$5;
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
let lastError;
|
|
2129
|
-
for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
|
|
2130
|
-
try {
|
|
2131
|
-
const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
|
|
2132
|
-
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
2133
|
-
// Write to cache after successful fetch
|
|
2134
|
-
await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
|
|
2135
|
-
return result;
|
|
2192
|
+
await Candle.acquireLock(`ClientExchange GET_CANDLES_FN symbol=${dto.symbol} interval=${dto.interval} limit=${dto.limit}`);
|
|
2193
|
+
try {
|
|
2194
|
+
// Try to read from cache first
|
|
2195
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN$1(dto, sinceTimestamp, untilTimestamp, self);
|
|
2196
|
+
if (cachedCandles !== null) {
|
|
2197
|
+
return cachedCandles;
|
|
2136
2198
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2199
|
+
// Cache miss or error - fetch from API
|
|
2200
|
+
let lastError;
|
|
2201
|
+
for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
|
|
2202
|
+
try {
|
|
2203
|
+
const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
|
|
2204
|
+
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
2205
|
+
// Write to cache after successful fetch
|
|
2206
|
+
await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
|
|
2207
|
+
return result;
|
|
2208
|
+
}
|
|
2209
|
+
catch (err) {
|
|
2210
|
+
const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
|
|
2211
|
+
const payload = {
|
|
2212
|
+
error: functoolsKit.errorData(err),
|
|
2213
|
+
message: functoolsKit.getErrorMessage(err),
|
|
2214
|
+
};
|
|
2215
|
+
self.params.logger.warn(message, payload);
|
|
2216
|
+
console.warn(message, payload);
|
|
2217
|
+
lastError = err;
|
|
2218
|
+
await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
|
|
2219
|
+
}
|
|
2147
2220
|
}
|
|
2221
|
+
throw lastError;
|
|
2222
|
+
}
|
|
2223
|
+
finally {
|
|
2224
|
+
Candle.releaseLock(`ClientExchange GET_CANDLES_FN symbol=${dto.symbol} interval=${dto.interval} limit=${dto.limit}`);
|
|
2148
2225
|
}
|
|
2149
|
-
throw lastError;
|
|
2150
2226
|
};
|
|
2151
2227
|
/**
|
|
2152
2228
|
* Wrapper to call onCandleData callback with error handling.
|
|
@@ -2829,8 +2905,9 @@ class ExchangeConnectionService {
|
|
|
2829
2905
|
*
|
|
2830
2906
|
* For signals with partial closes:
|
|
2831
2907
|
* - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
|
|
2832
|
-
* - Each partial close has its own
|
|
2833
|
-
* -
|
|
2908
|
+
* - Each partial close has its own slippage
|
|
2909
|
+
* - Open fee is charged once; close fees are proportional to each partial's size
|
|
2910
|
+
* - Total fees = CC_PERCENT_FEE (open) + CC_PERCENT_FEE × 1 (closes sum to 100%) = 2 × CC_PERCENT_FEE
|
|
2834
2911
|
*
|
|
2835
2912
|
* Formula breakdown:
|
|
2836
2913
|
* 1. Apply slippage to open/close prices (worse execution)
|
|
@@ -2877,7 +2954,8 @@ const toProfitLossDto = (signal, priceClose) => {
|
|
|
2877
2954
|
// Calculate weighted PNL with partial closes
|
|
2878
2955
|
if (signal._partial && signal._partial.length > 0) {
|
|
2879
2956
|
let totalWeightedPnl = 0;
|
|
2880
|
-
|
|
2957
|
+
// Open fee is paid once for the whole position
|
|
2958
|
+
let totalFees = GLOBAL_CONFIG.CC_PERCENT_FEE;
|
|
2881
2959
|
// Calculate PNL for each partial close
|
|
2882
2960
|
for (const partial of signal._partial) {
|
|
2883
2961
|
const partialPercent = partial.percent;
|
|
@@ -2904,8 +2982,8 @@ const toProfitLossDto = (signal, priceClose) => {
|
|
|
2904
2982
|
// Weight by percentage of position closed
|
|
2905
2983
|
const weightedPnl = (partialPercent / 100) * partialPnl;
|
|
2906
2984
|
totalWeightedPnl += weightedPnl;
|
|
2907
|
-
//
|
|
2908
|
-
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE *
|
|
2985
|
+
// Close fee is proportional to the size of this partial
|
|
2986
|
+
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (partialPercent / 100);
|
|
2909
2987
|
}
|
|
2910
2988
|
// Calculate PNL for remaining position (if any)
|
|
2911
2989
|
// Compute totalClosed from _partial array
|
|
@@ -2934,10 +3012,11 @@ const toProfitLossDto = (signal, priceClose) => {
|
|
|
2934
3012
|
// Weight by remaining percentage
|
|
2935
3013
|
const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
|
|
2936
3014
|
totalWeightedPnl += weightedRemainingPnl;
|
|
2937
|
-
//
|
|
2938
|
-
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE *
|
|
3015
|
+
// Close fee is proportional to the remaining size
|
|
3016
|
+
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (remainingPercent / 100);
|
|
2939
3017
|
}
|
|
2940
3018
|
// Subtract total fees from weighted PNL
|
|
3019
|
+
// totalFees = CC_PERCENT_FEE (open) + CC_PERCENT_FEE × 1 (all closes sum to 100%) = 2 × CC_PERCENT_FEE
|
|
2941
3020
|
const pnlPercentage = totalWeightedPnl - totalFees;
|
|
2942
3021
|
return {
|
|
2943
3022
|
pnlPercentage,
|
|
@@ -26327,55 +26406,61 @@ class ExchangeInstance {
|
|
|
26327
26406
|
const sinceTimestamp = alignedWhen - limit * stepMs;
|
|
26328
26407
|
const since = new Date(sinceTimestamp);
|
|
26329
26408
|
const untilTimestamp = alignedWhen;
|
|
26330
|
-
|
|
26331
|
-
|
|
26332
|
-
|
|
26333
|
-
|
|
26334
|
-
|
|
26335
|
-
|
|
26336
|
-
|
|
26337
|
-
|
|
26338
|
-
|
|
26339
|
-
|
|
26340
|
-
|
|
26341
|
-
|
|
26342
|
-
const
|
|
26343
|
-
|
|
26344
|
-
|
|
26345
|
-
|
|
26346
|
-
|
|
26347
|
-
|
|
26348
|
-
|
|
26409
|
+
await Candle.acquireLock(`ExchangeInstance.getCandles symbol=${symbol} interval=${interval} limit=${limit}`);
|
|
26410
|
+
try {
|
|
26411
|
+
// Try to read from cache first
|
|
26412
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
26413
|
+
if (cachedCandles !== null) {
|
|
26414
|
+
return cachedCandles;
|
|
26415
|
+
}
|
|
26416
|
+
let allData = [];
|
|
26417
|
+
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
26418
|
+
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
26419
|
+
let remaining = limit;
|
|
26420
|
+
let currentSince = new Date(since.getTime());
|
|
26421
|
+
const isBacktest = await GET_BACKTEST_FN();
|
|
26422
|
+
while (remaining > 0) {
|
|
26423
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
26424
|
+
const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
|
|
26425
|
+
allData.push(...chunkData);
|
|
26426
|
+
remaining -= chunkLimit;
|
|
26427
|
+
if (remaining > 0) {
|
|
26428
|
+
// Move currentSince forward by the number of candles fetched
|
|
26429
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
26430
|
+
}
|
|
26349
26431
|
}
|
|
26350
26432
|
}
|
|
26433
|
+
else {
|
|
26434
|
+
const isBacktest = await GET_BACKTEST_FN();
|
|
26435
|
+
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
26436
|
+
}
|
|
26437
|
+
// Apply distinct by timestamp to remove duplicates
|
|
26438
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26439
|
+
if (allData.length !== uniqueData.length) {
|
|
26440
|
+
bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26441
|
+
}
|
|
26442
|
+
// Validate adapter returned data
|
|
26443
|
+
if (uniqueData.length === 0) {
|
|
26444
|
+
throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
|
|
26445
|
+
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26446
|
+
}
|
|
26447
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26448
|
+
throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
|
|
26449
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26450
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26451
|
+
}
|
|
26452
|
+
if (uniqueData.length !== limit) {
|
|
26453
|
+
throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
|
|
26454
|
+
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
26455
|
+
`Adapter must return exact number of candles requested.`);
|
|
26456
|
+
}
|
|
26457
|
+
// Write to cache after successful fetch
|
|
26458
|
+
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
26459
|
+
return uniqueData;
|
|
26351
26460
|
}
|
|
26352
|
-
|
|
26353
|
-
|
|
26354
|
-
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
26355
|
-
}
|
|
26356
|
-
// Apply distinct by timestamp to remove duplicates
|
|
26357
|
-
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26358
|
-
if (allData.length !== uniqueData.length) {
|
|
26359
|
-
bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26360
|
-
}
|
|
26361
|
-
// Validate adapter returned data
|
|
26362
|
-
if (uniqueData.length === 0) {
|
|
26363
|
-
throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
|
|
26364
|
-
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26365
|
-
}
|
|
26366
|
-
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26367
|
-
throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
|
|
26368
|
-
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26369
|
-
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26370
|
-
}
|
|
26371
|
-
if (uniqueData.length !== limit) {
|
|
26372
|
-
throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
|
|
26373
|
-
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
26374
|
-
`Adapter must return exact number of candles requested.`);
|
|
26461
|
+
finally {
|
|
26462
|
+
Candle.releaseLock(`ExchangeInstance.getCandles symbol=${symbol} interval=${interval} limit=${limit}`);
|
|
26375
26463
|
}
|
|
26376
|
-
// Write to cache after successful fetch
|
|
26377
|
-
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
26378
|
-
return uniqueData;
|
|
26379
26464
|
};
|
|
26380
26465
|
/**
|
|
26381
26466
|
* Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
|
|
@@ -26609,56 +26694,62 @@ class ExchangeInstance {
|
|
|
26609
26694
|
`Provide one of: (sDate+eDate+limit), (sDate+eDate), (eDate+limit), (sDate+limit), or (limit only). ` +
|
|
26610
26695
|
`Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
|
|
26611
26696
|
}
|
|
26612
|
-
|
|
26613
|
-
|
|
26614
|
-
|
|
26615
|
-
|
|
26616
|
-
|
|
26617
|
-
|
|
26618
|
-
|
|
26619
|
-
|
|
26620
|
-
|
|
26621
|
-
|
|
26622
|
-
|
|
26623
|
-
|
|
26624
|
-
|
|
26625
|
-
|
|
26626
|
-
|
|
26627
|
-
|
|
26628
|
-
|
|
26629
|
-
|
|
26630
|
-
|
|
26631
|
-
|
|
26632
|
-
|
|
26697
|
+
await Candle.acquireLock(`ExchangeInstance.getRawCandles symbol=${symbol} interval=${interval} limit=${calculatedLimit}`);
|
|
26698
|
+
try {
|
|
26699
|
+
// Try to read from cache first
|
|
26700
|
+
const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
|
|
26701
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
26702
|
+
if (cachedCandles !== null) {
|
|
26703
|
+
return cachedCandles;
|
|
26704
|
+
}
|
|
26705
|
+
// Fetch candles
|
|
26706
|
+
const since = new Date(sinceTimestamp);
|
|
26707
|
+
let allData = [];
|
|
26708
|
+
const isBacktest = await GET_BACKTEST_FN();
|
|
26709
|
+
const getCandles = this._methods.getCandles;
|
|
26710
|
+
if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
26711
|
+
let remaining = calculatedLimit;
|
|
26712
|
+
let currentSince = new Date(since.getTime());
|
|
26713
|
+
while (remaining > 0) {
|
|
26714
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
26715
|
+
const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
|
|
26716
|
+
allData.push(...chunkData);
|
|
26717
|
+
remaining -= chunkLimit;
|
|
26718
|
+
if (remaining > 0) {
|
|
26719
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
26720
|
+
}
|
|
26633
26721
|
}
|
|
26634
26722
|
}
|
|
26723
|
+
else {
|
|
26724
|
+
allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
|
|
26725
|
+
}
|
|
26726
|
+
// Apply distinct by timestamp to remove duplicates
|
|
26727
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26728
|
+
if (allData.length !== uniqueData.length) {
|
|
26729
|
+
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26730
|
+
}
|
|
26731
|
+
// Validate adapter returned data
|
|
26732
|
+
if (uniqueData.length === 0) {
|
|
26733
|
+
throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
|
|
26734
|
+
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26735
|
+
}
|
|
26736
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26737
|
+
throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
|
|
26738
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26739
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26740
|
+
}
|
|
26741
|
+
if (uniqueData.length !== calculatedLimit) {
|
|
26742
|
+
throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
|
|
26743
|
+
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
26744
|
+
`Adapter must return exact number of candles requested.`);
|
|
26745
|
+
}
|
|
26746
|
+
// Write to cache after successful fetch
|
|
26747
|
+
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
|
|
26748
|
+
return uniqueData;
|
|
26635
26749
|
}
|
|
26636
|
-
|
|
26637
|
-
|
|
26638
|
-
}
|
|
26639
|
-
// Apply distinct by timestamp to remove duplicates
|
|
26640
|
-
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26641
|
-
if (allData.length !== uniqueData.length) {
|
|
26642
|
-
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26643
|
-
}
|
|
26644
|
-
// Validate adapter returned data
|
|
26645
|
-
if (uniqueData.length === 0) {
|
|
26646
|
-
throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
|
|
26647
|
-
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26648
|
-
}
|
|
26649
|
-
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26650
|
-
throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
|
|
26651
|
-
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26652
|
-
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26653
|
-
}
|
|
26654
|
-
if (uniqueData.length !== calculatedLimit) {
|
|
26655
|
-
throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
|
|
26656
|
-
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
26657
|
-
`Adapter must return exact number of candles requested.`);
|
|
26750
|
+
finally {
|
|
26751
|
+
Candle.releaseLock(`ExchangeInstance.getRawCandles symbol=${symbol} interval=${interval} limit=${calculatedLimit}`);
|
|
26658
26752
|
}
|
|
26659
|
-
// Write to cache after successful fetch
|
|
26660
|
-
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
|
|
26661
|
-
return uniqueData;
|
|
26662
26753
|
};
|
|
26663
26754
|
const schema = bt.exchangeSchemaService.get(this.exchangeName);
|
|
26664
26755
|
this._methods = CREATE_EXCHANGE_INSTANCE_FN(schema);
|
|
@@ -30145,6 +30236,115 @@ function listenStrategyCommitOnce(filterFn, fn) {
|
|
|
30145
30236
|
return strategyCommitSubject.filter(filterFn).once(fn);
|
|
30146
30237
|
}
|
|
30147
30238
|
|
|
30239
|
+
const WARN_KB = 30;
|
|
30240
|
+
const DUMP_MESSAGES_METHOD_NAME = "dump.dumpMessages";
|
|
30241
|
+
/**
|
|
30242
|
+
* Dumps chat history and result data to markdown files in a structured directory.
|
|
30243
|
+
*
|
|
30244
|
+
* Creates a subfolder named after `resultId` inside `outputDir`.
|
|
30245
|
+
* If the subfolder already exists, the function returns early without overwriting.
|
|
30246
|
+
* Writes:
|
|
30247
|
+
* - `00_system_prompt.md` — system messages and output data summary
|
|
30248
|
+
* - `NN_user_message.md` — each user message as a separate file
|
|
30249
|
+
* - `NN_llm_output.md` — final LLM output data
|
|
30250
|
+
*
|
|
30251
|
+
* Warns via logger if any user message exceeds 30 KB.
|
|
30252
|
+
*
|
|
30253
|
+
* @param resultId - Unique identifier for the result (used as subfolder name)
|
|
30254
|
+
* @param history - Full chat history containing system, user, and assistant messages
|
|
30255
|
+
* @param result - Structured output data to include in the dump
|
|
30256
|
+
* @param outputDir - Base directory for output files (default: `./dump/strategy`)
|
|
30257
|
+
* @returns Promise that resolves when all files are written
|
|
30258
|
+
*
|
|
30259
|
+
* @example
|
|
30260
|
+
* ```typescript
|
|
30261
|
+
* import { dumpMessages } from "backtest-kit";
|
|
30262
|
+
*
|
|
30263
|
+
* await dumpMessages("result-123", history, { profit: 42 });
|
|
30264
|
+
* ```
|
|
30265
|
+
*/
|
|
30266
|
+
async function dumpMessages(resultId, history, result, outputDir = "./dump/strategy") {
|
|
30267
|
+
bt.loggerService.info(DUMP_MESSAGES_METHOD_NAME, {
|
|
30268
|
+
resultId,
|
|
30269
|
+
outputDir,
|
|
30270
|
+
});
|
|
30271
|
+
// Extract system messages and system reminders from existing data
|
|
30272
|
+
const systemMessages = history.filter((m) => m.role === "system");
|
|
30273
|
+
const userMessages = history.filter((m) => m.role === "user");
|
|
30274
|
+
const subfolderPath = path.join(outputDir, String(resultId));
|
|
30275
|
+
try {
|
|
30276
|
+
await fs.access(subfolderPath);
|
|
30277
|
+
return;
|
|
30278
|
+
}
|
|
30279
|
+
catch {
|
|
30280
|
+
await fs.mkdir(subfolderPath, { recursive: true });
|
|
30281
|
+
}
|
|
30282
|
+
{
|
|
30283
|
+
let summary = "# Outline Result Summary\n";
|
|
30284
|
+
{
|
|
30285
|
+
summary += "\n";
|
|
30286
|
+
summary += `**ResultId**: ${resultId}\n`;
|
|
30287
|
+
summary += "\n";
|
|
30288
|
+
}
|
|
30289
|
+
if (result) {
|
|
30290
|
+
summary += "## Output Data\n\n";
|
|
30291
|
+
summary += "```json\n";
|
|
30292
|
+
summary += JSON.stringify(result, null, 2);
|
|
30293
|
+
summary += "\n```\n\n";
|
|
30294
|
+
}
|
|
30295
|
+
// Add system messages to summary
|
|
30296
|
+
if (systemMessages.length > 0) {
|
|
30297
|
+
summary += "## System Messages\n\n";
|
|
30298
|
+
systemMessages.forEach((msg, idx) => {
|
|
30299
|
+
summary += `### System Message ${idx + 1}\n\n`;
|
|
30300
|
+
summary += msg.content;
|
|
30301
|
+
summary += "\n";
|
|
30302
|
+
});
|
|
30303
|
+
}
|
|
30304
|
+
const summaryFile = path.join(subfolderPath, "00_system_prompt.md");
|
|
30305
|
+
await fs.writeFile(summaryFile, summary, "utf8");
|
|
30306
|
+
}
|
|
30307
|
+
{
|
|
30308
|
+
await Promise.all(Array.from(userMessages.entries()).map(async ([idx, message]) => {
|
|
30309
|
+
const messageNum = String(idx + 1).padStart(2, "0");
|
|
30310
|
+
const contentFileName = `${messageNum}_user_message.md`;
|
|
30311
|
+
const contentFilePath = path.join(subfolderPath, contentFileName);
|
|
30312
|
+
{
|
|
30313
|
+
const messageSizeBytes = Buffer.byteLength(message.content, "utf8");
|
|
30314
|
+
const messageSizeKb = Math.floor(messageSizeBytes / 1024);
|
|
30315
|
+
if (messageSizeKb > WARN_KB) {
|
|
30316
|
+
console.warn(`User message ${idx + 1} is ${messageSizeBytes} bytes (${messageSizeKb}kb), which exceeds warning limit`);
|
|
30317
|
+
bt.loggerService.warn(DUMP_MESSAGES_METHOD_NAME, {
|
|
30318
|
+
resultId,
|
|
30319
|
+
messageIndex: idx + 1,
|
|
30320
|
+
messageSizeBytes,
|
|
30321
|
+
messageSizeKb,
|
|
30322
|
+
});
|
|
30323
|
+
}
|
|
30324
|
+
}
|
|
30325
|
+
let content = `# User Input ${idx + 1}\n\n`;
|
|
30326
|
+
content += `**ResultId**: ${resultId}\n\n`;
|
|
30327
|
+
content += message.content;
|
|
30328
|
+
content += "\n";
|
|
30329
|
+
await fs.writeFile(contentFilePath, content, "utf8");
|
|
30330
|
+
}));
|
|
30331
|
+
}
|
|
30332
|
+
{
|
|
30333
|
+
const messageNum = String(userMessages.length + 1).padStart(2, "0");
|
|
30334
|
+
const contentFileName = `${messageNum}_llm_output.md`;
|
|
30335
|
+
const contentFilePath = path.join(subfolderPath, contentFileName);
|
|
30336
|
+
let content = "# Full Outline Result\n\n";
|
|
30337
|
+
content += `**ResultId**: ${resultId}\n\n`;
|
|
30338
|
+
if (result) {
|
|
30339
|
+
content += "## Output Data\n\n";
|
|
30340
|
+
content += "```json\n";
|
|
30341
|
+
content += JSON.stringify(result, null, 2);
|
|
30342
|
+
content += "\n```\n";
|
|
30343
|
+
}
|
|
30344
|
+
await fs.writeFile(contentFilePath, content, "utf8");
|
|
30345
|
+
}
|
|
30346
|
+
}
|
|
30347
|
+
|
|
30148
30348
|
const BACKTEST_METHOD_NAME_RUN = "BacktestUtils.run";
|
|
30149
30349
|
const BACKTEST_METHOD_NAME_BACKGROUND = "BacktestUtils.background";
|
|
30150
30350
|
const BACKTEST_METHOD_NAME_STOP = "BacktestUtils.stop";
|
|
@@ -37458,6 +37658,7 @@ exports.commitPartialLoss = commitPartialLoss;
|
|
|
37458
37658
|
exports.commitPartialProfit = commitPartialProfit;
|
|
37459
37659
|
exports.commitTrailingStop = commitTrailingStop;
|
|
37460
37660
|
exports.commitTrailingTake = commitTrailingTake;
|
|
37661
|
+
exports.dumpMessages = dumpMessages;
|
|
37461
37662
|
exports.emitters = emitters;
|
|
37462
37663
|
exports.formatPrice = formatPrice;
|
|
37463
37664
|
exports.formatQuantity = formatQuantity;
|