backtest-kit 1.0.3 → 1.1.0
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 +707 -153
- package/build/index.cjs +4132 -407
- package/build/index.mjs +4116 -401
- package/package.json +2 -9
- package/types.d.ts +2432 -69
package/build/index.mjs
CHANGED
|
@@ -1,63 +1,215 @@
|
|
|
1
1
|
import { createActivator } from 'di-kit';
|
|
2
|
-
import { memoize, singlerun, sleep } from 'functools-kit';
|
|
3
2
|
import { scoped } from 'di-scoped';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
debug() {
|
|
10
|
-
},
|
|
11
|
-
info() {
|
|
12
|
-
},
|
|
13
|
-
};
|
|
14
|
-
class LoggerService {
|
|
15
|
-
constructor() {
|
|
16
|
-
this._commonLogger = NOOP_LOGGER;
|
|
17
|
-
this.log = async (topic, ...args) => {
|
|
18
|
-
await this._commonLogger.log(topic, ...args);
|
|
19
|
-
};
|
|
20
|
-
this.debug = async (topic, ...args) => {
|
|
21
|
-
await this._commonLogger.debug(topic, ...args);
|
|
22
|
-
};
|
|
23
|
-
this.info = async (topic, ...args) => {
|
|
24
|
-
await this._commonLogger.info(topic, ...args);
|
|
25
|
-
};
|
|
26
|
-
this.setLogger = (logger) => {
|
|
27
|
-
this._commonLogger = logger;
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
}
|
|
3
|
+
import { memoize, makeExtendable, singleshot, getErrorMessage, not, trycatch, retry, randomString, Subject, ToolRegistry, sleep, str, queued } from 'functools-kit';
|
|
4
|
+
import fs, { mkdir, writeFile } from 'fs/promises';
|
|
5
|
+
import path, { join } from 'path';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import os from 'os';
|
|
31
8
|
|
|
32
9
|
const { init, inject, provide } = createActivator("backtest");
|
|
33
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Scoped service for method context propagation.
|
|
13
|
+
*
|
|
14
|
+
* Uses di-scoped for implicit context passing without explicit parameters.
|
|
15
|
+
* Context includes strategyName, exchangeName, and frameName.
|
|
16
|
+
*
|
|
17
|
+
* Used by PublicServices to inject schema names into ConnectionServices.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* MethodContextService.runAsyncIterator(
|
|
22
|
+
* backtestGenerator,
|
|
23
|
+
* {
|
|
24
|
+
* strategyName: "my-strategy",
|
|
25
|
+
* exchangeName: "my-exchange",
|
|
26
|
+
* frameName: "1d-backtest"
|
|
27
|
+
* }
|
|
28
|
+
* );
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
const MethodContextService = scoped(class {
|
|
32
|
+
constructor(context) {
|
|
33
|
+
this.context = context;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
34
37
|
const baseServices$1 = {
|
|
35
38
|
loggerService: Symbol('loggerService'),
|
|
36
39
|
};
|
|
37
40
|
const contextServices$1 = {
|
|
38
41
|
executionContextService: Symbol('executionContextService'),
|
|
42
|
+
methodContextService: Symbol('methodContextService'),
|
|
39
43
|
};
|
|
40
44
|
const connectionServices$1 = {
|
|
41
45
|
exchangeConnectionService: Symbol('exchangeConnectionService'),
|
|
42
46
|
strategyConnectionService: Symbol('strategyConnectionService'),
|
|
47
|
+
frameConnectionService: Symbol('frameConnectionService'),
|
|
43
48
|
};
|
|
44
49
|
const schemaServices$1 = {
|
|
45
50
|
exchangeSchemaService: Symbol('exchangeSchemaService'),
|
|
46
51
|
strategySchemaService: Symbol('strategySchemaService'),
|
|
52
|
+
frameSchemaService: Symbol('frameSchemaService'),
|
|
53
|
+
};
|
|
54
|
+
const globalServices$1 = {
|
|
55
|
+
exchangeGlobalService: Symbol('exchangeGlobalService'),
|
|
56
|
+
strategyGlobalService: Symbol('strategyGlobalService'),
|
|
57
|
+
frameGlobalService: Symbol('frameGlobalService'),
|
|
58
|
+
liveGlobalService: Symbol('liveGlobalService'),
|
|
59
|
+
backtestGlobalService: Symbol('backtestGlobalService'),
|
|
60
|
+
};
|
|
61
|
+
const logicPrivateServices$1 = {
|
|
62
|
+
backtestLogicPrivateService: Symbol('backtestLogicPrivateService'),
|
|
63
|
+
liveLogicPrivateService: Symbol('liveLogicPrivateService'),
|
|
64
|
+
};
|
|
65
|
+
const logicPublicServices$1 = {
|
|
66
|
+
backtestLogicPublicService: Symbol('backtestLogicPublicService'),
|
|
67
|
+
liveLogicPublicService: Symbol('liveLogicPublicService'),
|
|
68
|
+
};
|
|
69
|
+
const markdownServices$1 = {
|
|
70
|
+
backtestMarkdownService: Symbol('backtestMarkdownService'),
|
|
71
|
+
liveMarkdownService: Symbol('liveMarkdownService'),
|
|
47
72
|
};
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
const validationServices$1 = {
|
|
74
|
+
exchangeValidationService: Symbol('exchangeValidationService'),
|
|
75
|
+
strategyValidationService: Symbol('strategyValidationService'),
|
|
76
|
+
frameValidationService: Symbol('frameValidationService'),
|
|
51
77
|
};
|
|
52
78
|
const TYPES = {
|
|
53
79
|
...baseServices$1,
|
|
54
80
|
...contextServices$1,
|
|
55
81
|
...connectionServices$1,
|
|
56
82
|
...schemaServices$1,
|
|
57
|
-
...
|
|
83
|
+
...globalServices$1,
|
|
84
|
+
...logicPrivateServices$1,
|
|
85
|
+
...logicPublicServices$1,
|
|
86
|
+
...markdownServices$1,
|
|
87
|
+
...validationServices$1,
|
|
58
88
|
};
|
|
59
89
|
|
|
60
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Scoped service for execution context propagation.
|
|
92
|
+
*
|
|
93
|
+
* Uses di-scoped for implicit context passing without explicit parameters.
|
|
94
|
+
* Context includes symbol, when (timestamp), and backtest flag.
|
|
95
|
+
*
|
|
96
|
+
* Used by GlobalServices to inject context into operations.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* ExecutionContextService.runInContext(
|
|
101
|
+
* async () => {
|
|
102
|
+
* // Inside this callback, context is automatically available
|
|
103
|
+
* return await someOperation();
|
|
104
|
+
* },
|
|
105
|
+
* { symbol: "BTCUSDT", when: new Date(), backtest: true }
|
|
106
|
+
* );
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
const ExecutionContextService = scoped(class {
|
|
110
|
+
constructor(context) {
|
|
111
|
+
this.context = context;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* No-op logger implementation used as default.
|
|
117
|
+
* Silently discards all log messages.
|
|
118
|
+
*/
|
|
119
|
+
const NOOP_LOGGER = {
|
|
120
|
+
log() {
|
|
121
|
+
},
|
|
122
|
+
debug() {
|
|
123
|
+
},
|
|
124
|
+
info() {
|
|
125
|
+
},
|
|
126
|
+
warn() {
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Logger service with automatic context injection.
|
|
131
|
+
*
|
|
132
|
+
* Features:
|
|
133
|
+
* - Delegates to user-provided logger via setLogger()
|
|
134
|
+
* - Automatically appends method context (strategyName, exchangeName, frameName)
|
|
135
|
+
* - Automatically appends execution context (symbol, when, backtest)
|
|
136
|
+
* - Defaults to NOOP_LOGGER if no logger configured
|
|
137
|
+
*
|
|
138
|
+
* Used throughout the framework for consistent logging with context.
|
|
139
|
+
*/
|
|
140
|
+
class LoggerService {
|
|
141
|
+
constructor() {
|
|
142
|
+
this.methodContextService = inject(TYPES.methodContextService);
|
|
143
|
+
this.executionContextService = inject(TYPES.executionContextService);
|
|
144
|
+
this._commonLogger = NOOP_LOGGER;
|
|
145
|
+
/**
|
|
146
|
+
* Logs general-purpose message with automatic context injection.
|
|
147
|
+
*
|
|
148
|
+
* @param topic - Log topic/category
|
|
149
|
+
* @param args - Additional log arguments
|
|
150
|
+
*/
|
|
151
|
+
this.log = async (topic, ...args) => {
|
|
152
|
+
await this._commonLogger.log(topic, ...args, this.methodContext, this.executionContext);
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Logs debug-level message with automatic context injection.
|
|
156
|
+
*
|
|
157
|
+
* @param topic - Log topic/category
|
|
158
|
+
* @param args - Additional log arguments
|
|
159
|
+
*/
|
|
160
|
+
this.debug = async (topic, ...args) => {
|
|
161
|
+
await this._commonLogger.debug(topic, ...args, this.methodContext, this.executionContext);
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Logs info-level message with automatic context injection.
|
|
165
|
+
*
|
|
166
|
+
* @param topic - Log topic/category
|
|
167
|
+
* @param args - Additional log arguments
|
|
168
|
+
*/
|
|
169
|
+
this.info = async (topic, ...args) => {
|
|
170
|
+
await this._commonLogger.info(topic, ...args, this.methodContext, this.executionContext);
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Logs warning-level message with automatic context injection.
|
|
174
|
+
*
|
|
175
|
+
* @param topic - Log topic/category
|
|
176
|
+
* @param args - Additional log arguments
|
|
177
|
+
*/
|
|
178
|
+
this.warn = async (topic, ...args) => {
|
|
179
|
+
await this._commonLogger.warn(topic, ...args, this.methodContext, this.executionContext);
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Sets custom logger implementation.
|
|
183
|
+
*
|
|
184
|
+
* @param logger - Custom logger implementing ILogger interface
|
|
185
|
+
*/
|
|
186
|
+
this.setLogger = (logger) => {
|
|
187
|
+
this._commonLogger = logger;
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Gets current method context if available.
|
|
192
|
+
* Contains strategyName, exchangeName, frameName from MethodContextService.
|
|
193
|
+
*/
|
|
194
|
+
get methodContext() {
|
|
195
|
+
if (MethodContextService.hasContext()) {
|
|
196
|
+
return this.methodContextService.context;
|
|
197
|
+
}
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Gets current execution context if available.
|
|
202
|
+
* Contains symbol, when, backtest from ExecutionContextService.
|
|
203
|
+
*/
|
|
204
|
+
get executionContext() {
|
|
205
|
+
if (ExecutionContextService.hasContext()) {
|
|
206
|
+
return this.executionContextService.context;
|
|
207
|
+
}
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const INTERVAL_MINUTES$2 = {
|
|
61
213
|
"1m": 1,
|
|
62
214
|
"3m": 3,
|
|
63
215
|
"5m": 5,
|
|
@@ -69,117 +221,334 @@ const INTERVAL_MINUTES = {
|
|
|
69
221
|
"6h": 360,
|
|
70
222
|
"8h": 480,
|
|
71
223
|
};
|
|
224
|
+
/**
|
|
225
|
+
* Client implementation for exchange data access.
|
|
226
|
+
*
|
|
227
|
+
* Features:
|
|
228
|
+
* - Historical candle fetching (backwards from execution context)
|
|
229
|
+
* - Future candle fetching (forwards for backtest)
|
|
230
|
+
* - VWAP calculation from last 5 1m candles
|
|
231
|
+
* - Price/quantity formatting for exchange
|
|
232
|
+
*
|
|
233
|
+
* All methods use prototype functions for memory efficiency.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```typescript
|
|
237
|
+
* const exchange = new ClientExchange({
|
|
238
|
+
* exchangeName: "binance",
|
|
239
|
+
* getCandles: async (symbol, interval, since, limit) => [...],
|
|
240
|
+
* formatPrice: async (symbol, price) => price.toFixed(2),
|
|
241
|
+
* formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
|
|
242
|
+
* execution: executionService,
|
|
243
|
+
* logger: loggerService,
|
|
244
|
+
* });
|
|
245
|
+
*
|
|
246
|
+
* const candles = await exchange.getCandles("BTCUSDT", "1m", 100);
|
|
247
|
+
* const vwap = await exchange.getAveragePrice("BTCUSDT");
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
72
250
|
class ClientExchange {
|
|
73
251
|
constructor(params) {
|
|
74
252
|
this.params = params;
|
|
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
|
-
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Fetches historical candles backwards from execution context time.
|
|
256
|
+
*
|
|
257
|
+
* @param symbol - Trading pair symbol
|
|
258
|
+
* @param interval - Candle interval
|
|
259
|
+
* @param limit - Number of candles to fetch
|
|
260
|
+
* @returns Promise resolving to array of candles
|
|
261
|
+
*/
|
|
262
|
+
async getCandles(symbol, interval, limit) {
|
|
263
|
+
this.params.logger.debug(`ClientExchange getCandles`, {
|
|
264
|
+
symbol,
|
|
265
|
+
interval,
|
|
266
|
+
limit,
|
|
267
|
+
});
|
|
268
|
+
const step = INTERVAL_MINUTES$2[interval];
|
|
269
|
+
const adjust = step * limit - step;
|
|
270
|
+
if (!adjust) {
|
|
271
|
+
throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
|
|
272
|
+
}
|
|
273
|
+
const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
|
|
274
|
+
const data = await this.params.getCandles(symbol, interval, since, limit);
|
|
275
|
+
// Filter candles to strictly match the requested range
|
|
276
|
+
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
277
|
+
const sinceTimestamp = since.getTime();
|
|
278
|
+
const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= whenTimestamp);
|
|
279
|
+
if (filteredData.length < limit) {
|
|
280
|
+
this.params.logger.warn(`ClientExchange Expected ${limit} candles, got ${filteredData.length}`);
|
|
281
|
+
}
|
|
282
|
+
if (this.params.callbacks?.onCandleData) {
|
|
283
|
+
this.params.callbacks.onCandleData(symbol, interval, since, limit, filteredData);
|
|
284
|
+
}
|
|
285
|
+
return filteredData;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Fetches future candles forwards from execution context time.
|
|
289
|
+
* Used in backtest mode to get candles for signal duration.
|
|
290
|
+
*
|
|
291
|
+
* @param symbol - Trading pair symbol
|
|
292
|
+
* @param interval - Candle interval
|
|
293
|
+
* @param limit - Number of candles to fetch
|
|
294
|
+
* @returns Promise resolving to array of candles
|
|
295
|
+
* @throws Error if trying to fetch future candles in live mode
|
|
296
|
+
*/
|
|
297
|
+
async getNextCandles(symbol, interval, limit) {
|
|
298
|
+
this.params.logger.debug(`ClientExchange getNextCandles`, {
|
|
299
|
+
symbol,
|
|
300
|
+
interval,
|
|
301
|
+
limit,
|
|
302
|
+
});
|
|
303
|
+
const since = new Date(this.params.execution.context.when.getTime());
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
// Вычисляем конечное время запроса
|
|
306
|
+
const step = INTERVAL_MINUTES$2[interval];
|
|
307
|
+
const endTime = since.getTime() + limit * step * 60 * 1000;
|
|
308
|
+
// Проверяем что запрошенный период не заходит за Date.now()
|
|
309
|
+
if (endTime > now) {
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
const data = await this.params.getCandles(symbol, interval, since, limit);
|
|
313
|
+
// Filter candles to strictly match the requested range
|
|
314
|
+
const sinceTimestamp = since.getTime();
|
|
315
|
+
const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= endTime);
|
|
316
|
+
if (filteredData.length < limit) {
|
|
317
|
+
this.params.logger.warn(`ClientExchange getNextCandles: Expected ${limit} candles, got ${filteredData.length}`);
|
|
318
|
+
}
|
|
319
|
+
if (this.params.callbacks?.onCandleData) {
|
|
320
|
+
this.params.callbacks.onCandleData(symbol, interval, since, limit, filteredData);
|
|
321
|
+
}
|
|
322
|
+
return filteredData;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Calculates VWAP (Volume Weighted Average Price) from last 5 1m candles.
|
|
326
|
+
*
|
|
327
|
+
* Formula:
|
|
328
|
+
* - Typical Price = (high + low + close) / 3
|
|
329
|
+
* - VWAP = sum(typical_price * volume) / sum(volume)
|
|
330
|
+
*
|
|
331
|
+
* If volume is zero, returns simple average of close prices.
|
|
332
|
+
*
|
|
333
|
+
* @param symbol - Trading pair symbol
|
|
334
|
+
* @returns Promise resolving to VWAP price
|
|
335
|
+
* @throws Error if no candles available
|
|
336
|
+
*/
|
|
337
|
+
async getAveragePrice(symbol) {
|
|
338
|
+
this.params.logger.debug(`ClientExchange getAveragePrice`, {
|
|
339
|
+
symbol,
|
|
340
|
+
});
|
|
341
|
+
const candles = await this.getCandles(symbol, "1m", 5);
|
|
342
|
+
if (candles.length === 0) {
|
|
343
|
+
throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`);
|
|
344
|
+
}
|
|
345
|
+
// VWAP (Volume Weighted Average Price)
|
|
346
|
+
// Используем типичную цену (typical price) = (high + low + close) / 3
|
|
347
|
+
const sumPriceVolume = candles.reduce((acc, candle) => {
|
|
348
|
+
const typicalPrice = (candle.high + candle.low + candle.close) / 3;
|
|
349
|
+
return acc + typicalPrice * candle.volume;
|
|
350
|
+
}, 0);
|
|
351
|
+
const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0);
|
|
352
|
+
if (totalVolume === 0) {
|
|
353
|
+
// Если объем нулевой, возвращаем простое среднее close цен
|
|
354
|
+
const sum = candles.reduce((acc, candle) => acc + candle.close, 0);
|
|
355
|
+
return sum / candles.length;
|
|
356
|
+
}
|
|
357
|
+
const vwap = sumPriceVolume / totalVolume;
|
|
358
|
+
return vwap;
|
|
359
|
+
}
|
|
360
|
+
async formatQuantity(symbol, quantity) {
|
|
361
|
+
this.params.logger.debug("binanceService formatQuantity", {
|
|
362
|
+
symbol,
|
|
363
|
+
quantity,
|
|
364
|
+
});
|
|
365
|
+
return await this.params.formatQuantity(symbol, quantity);
|
|
366
|
+
}
|
|
367
|
+
async formatPrice(symbol, price) {
|
|
368
|
+
this.params.logger.debug("binanceService formatPrice", {
|
|
369
|
+
symbol,
|
|
370
|
+
price,
|
|
371
|
+
});
|
|
372
|
+
return await this.params.formatPrice(symbol, price);
|
|
130
373
|
}
|
|
131
374
|
}
|
|
132
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Connection service routing exchange operations to correct ClientExchange instance.
|
|
378
|
+
*
|
|
379
|
+
* Routes all IExchange method calls to the appropriate exchange implementation
|
|
380
|
+
* based on methodContextService.context.exchangeName. Uses memoization to cache
|
|
381
|
+
* ClientExchange instances for performance.
|
|
382
|
+
*
|
|
383
|
+
* Key features:
|
|
384
|
+
* - Automatic exchange routing via method context
|
|
385
|
+
* - Memoized ClientExchange instances by exchangeName
|
|
386
|
+
* - Implements full IExchange interface
|
|
387
|
+
* - Logging for all operations
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```typescript
|
|
391
|
+
* // Used internally by framework
|
|
392
|
+
* const candles = await exchangeConnectionService.getCandles(
|
|
393
|
+
* "BTCUSDT", "1h", 100
|
|
394
|
+
* );
|
|
395
|
+
* // Automatically routes to correct exchange based on methodContext
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
133
398
|
class ExchangeConnectionService {
|
|
134
399
|
constructor() {
|
|
135
400
|
this.loggerService = inject(TYPES.loggerService);
|
|
136
401
|
this.executionContextService = inject(TYPES.executionContextService);
|
|
137
402
|
this.exchangeSchemaService = inject(TYPES.exchangeSchemaService);
|
|
138
|
-
this.
|
|
139
|
-
|
|
403
|
+
this.methodContextService = inject(TYPES.methodContextService);
|
|
404
|
+
/**
|
|
405
|
+
* Retrieves memoized ClientExchange instance for given exchange name.
|
|
406
|
+
*
|
|
407
|
+
* Creates ClientExchange on first call, returns cached instance on subsequent calls.
|
|
408
|
+
* Cache key is exchangeName string.
|
|
409
|
+
*
|
|
410
|
+
* @param exchangeName - Name of registered exchange schema
|
|
411
|
+
* @returns Configured ClientExchange instance
|
|
412
|
+
*/
|
|
413
|
+
this.getExchange = memoize(([exchangeName]) => `${exchangeName}`, (exchangeName) => {
|
|
414
|
+
const { getCandles, formatPrice, formatQuantity, callbacks } = this.exchangeSchemaService.get(exchangeName);
|
|
140
415
|
return new ClientExchange({
|
|
141
416
|
execution: this.executionContextService,
|
|
142
417
|
logger: this.loggerService,
|
|
418
|
+
exchangeName,
|
|
143
419
|
getCandles,
|
|
144
420
|
formatPrice,
|
|
145
421
|
formatQuantity,
|
|
146
422
|
callbacks,
|
|
147
423
|
});
|
|
148
424
|
});
|
|
425
|
+
/**
|
|
426
|
+
* Fetches historical candles for symbol using configured exchange.
|
|
427
|
+
*
|
|
428
|
+
* Routes to exchange determined by methodContextService.context.exchangeName.
|
|
429
|
+
*
|
|
430
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
431
|
+
* @param interval - Candle interval (e.g., "1h", "1d")
|
|
432
|
+
* @param limit - Maximum number of candles to fetch
|
|
433
|
+
* @returns Promise resolving to array of candle data
|
|
434
|
+
*/
|
|
149
435
|
this.getCandles = async (symbol, interval, limit) => {
|
|
150
436
|
this.loggerService.log("exchangeConnectionService getCandles", {
|
|
151
437
|
symbol,
|
|
152
438
|
interval,
|
|
153
439
|
limit,
|
|
154
440
|
});
|
|
155
|
-
return await this.getExchange(
|
|
441
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getCandles(symbol, interval, limit);
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* Fetches next batch of candles relative to executionContext.when.
|
|
445
|
+
*
|
|
446
|
+
* Returns candles that come after the current execution timestamp.
|
|
447
|
+
* Used for backtest progression and live trading updates.
|
|
448
|
+
*
|
|
449
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
450
|
+
* @param interval - Candle interval (e.g., "1h", "1d")
|
|
451
|
+
* @param limit - Maximum number of candles to fetch
|
|
452
|
+
* @returns Promise resolving to array of candle data
|
|
453
|
+
*/
|
|
454
|
+
this.getNextCandles = async (symbol, interval, limit) => {
|
|
455
|
+
this.loggerService.log("exchangeConnectionService getNextCandles", {
|
|
456
|
+
symbol,
|
|
457
|
+
interval,
|
|
458
|
+
limit,
|
|
459
|
+
});
|
|
460
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getNextCandles(symbol, interval, limit);
|
|
156
461
|
};
|
|
462
|
+
/**
|
|
463
|
+
* Retrieves current average price for symbol.
|
|
464
|
+
*
|
|
465
|
+
* In live mode: fetches real-time average price from exchange API.
|
|
466
|
+
* In backtest mode: calculates VWAP from candles in current timeframe.
|
|
467
|
+
*
|
|
468
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
469
|
+
* @returns Promise resolving to average price
|
|
470
|
+
*/
|
|
157
471
|
this.getAveragePrice = async (symbol) => {
|
|
158
472
|
this.loggerService.log("exchangeConnectionService getAveragePrice", {
|
|
159
473
|
symbol,
|
|
160
474
|
});
|
|
161
|
-
return await this.getExchange(
|
|
475
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
|
|
162
476
|
};
|
|
477
|
+
/**
|
|
478
|
+
* Formats price according to exchange-specific precision rules.
|
|
479
|
+
*
|
|
480
|
+
* Ensures price meets exchange requirements for decimal places and tick size.
|
|
481
|
+
*
|
|
482
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
483
|
+
* @param price - Raw price value to format
|
|
484
|
+
* @returns Promise resolving to formatted price string
|
|
485
|
+
*/
|
|
163
486
|
this.formatPrice = async (symbol, price) => {
|
|
164
487
|
this.loggerService.log("exchangeConnectionService getAveragePrice", {
|
|
165
488
|
symbol,
|
|
166
489
|
price,
|
|
167
490
|
});
|
|
168
|
-
return await this.getExchange(
|
|
491
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).formatPrice(symbol, price);
|
|
169
492
|
};
|
|
493
|
+
/**
|
|
494
|
+
* Formats quantity according to exchange-specific precision rules.
|
|
495
|
+
*
|
|
496
|
+
* Ensures quantity meets exchange requirements for decimal places and lot size.
|
|
497
|
+
*
|
|
498
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
499
|
+
* @param quantity - Raw quantity value to format
|
|
500
|
+
* @returns Promise resolving to formatted quantity string
|
|
501
|
+
*/
|
|
170
502
|
this.formatQuantity = async (symbol, quantity) => {
|
|
171
503
|
this.loggerService.log("exchangeConnectionService getAveragePrice", {
|
|
172
504
|
symbol,
|
|
173
505
|
quantity,
|
|
174
506
|
});
|
|
175
|
-
return await this.getExchange(
|
|
507
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).formatQuantity(symbol, quantity);
|
|
176
508
|
};
|
|
177
509
|
}
|
|
178
510
|
}
|
|
179
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Slippage percentage applied to entry and exit prices.
|
|
514
|
+
* Simulates market impact and order book depth.
|
|
515
|
+
*/
|
|
180
516
|
const PERCENT_SLIPPAGE = 0.1;
|
|
517
|
+
/**
|
|
518
|
+
* Fee percentage charged per transaction.
|
|
519
|
+
* Applied twice (entry and exit) for total fee calculation.
|
|
520
|
+
*/
|
|
181
521
|
const PERCENT_FEE = 0.1;
|
|
182
|
-
|
|
522
|
+
/**
|
|
523
|
+
* Calculates profit/loss for a closed signal with slippage and fees.
|
|
524
|
+
*
|
|
525
|
+
* Formula breakdown:
|
|
526
|
+
* 1. Apply slippage to open/close prices (worse execution)
|
|
527
|
+
* - LONG: buy higher (+slippage), sell lower (-slippage)
|
|
528
|
+
* - SHORT: sell lower (-slippage), buy higher (+slippage)
|
|
529
|
+
* 2. Calculate raw PNL percentage
|
|
530
|
+
* - LONG: ((closePrice - openPrice) / openPrice) * 100
|
|
531
|
+
* - SHORT: ((openPrice - closePrice) / openPrice) * 100
|
|
532
|
+
* 3. Subtract total fees (0.1% * 2 = 0.2%)
|
|
533
|
+
*
|
|
534
|
+
* @param signal - Closed signal with position details
|
|
535
|
+
* @param priceClose - Actual close price at exit
|
|
536
|
+
* @returns PNL data with percentage and prices
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* ```typescript
|
|
540
|
+
* const pnl = toProfitLossDto(
|
|
541
|
+
* {
|
|
542
|
+
* position: "long",
|
|
543
|
+
* priceOpen: 50000,
|
|
544
|
+
* // ... other signal fields
|
|
545
|
+
* },
|
|
546
|
+
* 51000 // close price
|
|
547
|
+
* );
|
|
548
|
+
* console.log(pnl.pnlPercentage); // e.g., 1.8% (after slippage and fees)
|
|
549
|
+
* ```
|
|
550
|
+
*/
|
|
551
|
+
const toProfitLossDto = (signal, priceClose) => {
|
|
183
552
|
const priceOpen = signal.priceOpen;
|
|
184
553
|
let priceOpenWithSlippage;
|
|
185
554
|
let priceCloseWithSlippage;
|
|
@@ -218,247 +587,2889 @@ const GET_PNL_FN = (signal, priceClose) => {
|
|
|
218
587
|
priceClose,
|
|
219
588
|
};
|
|
220
589
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
590
|
+
|
|
591
|
+
const IS_WINDOWS = os.platform() === "win32";
|
|
592
|
+
/**
|
|
593
|
+
* Atomically writes data to a file, ensuring the operation either fully completes or leaves the original file unchanged.
|
|
594
|
+
* Uses a temporary file with a rename strategy on POSIX systems for atomicity, or direct writing with sync on Windows (or when POSIX rename is skipped).
|
|
595
|
+
*
|
|
596
|
+
*
|
|
597
|
+
* @param {string} file - The file parameter.
|
|
598
|
+
* @param {string | Buffer} data - The data to be processed or validated.
|
|
599
|
+
* @param {Options | BufferEncoding} options - The options parameter (optional).
|
|
600
|
+
* @throws {Error} Throws an error if the write, sync, or rename operation fails, after attempting cleanup of temporary files.
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* // Basic usage with default options
|
|
604
|
+
* await writeFileAtomic("output.txt", "Hello, world!");
|
|
605
|
+
* // Writes "Hello, world!" to "output.txt" atomically
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* // Custom options and Buffer data
|
|
609
|
+
* const buffer = Buffer.from("Binary data");
|
|
610
|
+
* await writeFileAtomic("data.bin", buffer, { encoding: "binary", mode: 0o644, tmpPrefix: "temp-" });
|
|
611
|
+
* // Writes binary data to "data.bin" with custom permissions and temp prefix
|
|
612
|
+
*
|
|
613
|
+
* @example
|
|
614
|
+
* // Using encoding shorthand
|
|
615
|
+
* await writeFileAtomic("log.txt", "Log entry", "utf16le");
|
|
616
|
+
* // Writes "Log entry" to "log.txt" in UTF-16LE encoding
|
|
617
|
+
*
|
|
618
|
+
* @remarks
|
|
619
|
+
* This function ensures atomicity to prevent partial writes:
|
|
620
|
+
* - On POSIX systems (non-Windows, unless `GLOBAL_CONFIG.CC_SKIP_POSIX_RENAME` is true):
|
|
621
|
+
* - Writes data to a temporary file (e.g., `.tmp-<random>-filename`) in the same directory.
|
|
622
|
+
* - Uses `crypto.randomBytes` to generate a unique temporary name, reducing collision risk.
|
|
623
|
+
* - Syncs the data to disk and renames the temporary file to the target file atomically with `fs.rename`.
|
|
624
|
+
* - Cleans up the temporary file on failure, swallowing cleanup errors to prioritize throwing the original error.
|
|
625
|
+
* - On Windows (or when POSIX rename is skipped):
|
|
626
|
+
* - Writes directly to the target file, syncing data to disk to minimize corruption risk (though not fully atomic).
|
|
627
|
+
* - Closes the file handle on failure without additional cleanup.
|
|
628
|
+
* - Accepts `options` as an object or a string (interpreted as `encoding`), defaulting to `{ encoding: "utf8", mode: 0o666, tmpPrefix: ".tmp-" }`.
|
|
629
|
+
* Useful in the agent swarm system for safely writing configuration files, logs, or state data where partial writes could cause corruption.
|
|
630
|
+
*
|
|
631
|
+
* @see {@link https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options|fs.promises.writeFile} for file writing details.
|
|
632
|
+
* @see {@link https://nodejs.org/api/crypto.html#cryptorandombytessize-callback|crypto.randomBytes} for temporary file naming.
|
|
633
|
+
* @see {@link ../config/params|GLOBAL_CONFIG} for configuration impacting POSIX behavior.
|
|
634
|
+
*/
|
|
635
|
+
async function writeFileAtomic(file, data, options = {}) {
|
|
636
|
+
if (typeof options === "string") {
|
|
637
|
+
options = { encoding: options };
|
|
638
|
+
}
|
|
639
|
+
else if (!options) {
|
|
640
|
+
options = {};
|
|
641
|
+
}
|
|
642
|
+
const { encoding = "utf8", mode = 0o666, tmpPrefix = ".tmp-" } = options;
|
|
643
|
+
let fileHandle = null;
|
|
644
|
+
if (IS_WINDOWS) {
|
|
645
|
+
try {
|
|
646
|
+
// Create and write to temporary file
|
|
647
|
+
fileHandle = await fs.open(file, "w", mode);
|
|
648
|
+
// Write data to the temp file
|
|
649
|
+
await fileHandle.writeFile(data, { encoding });
|
|
650
|
+
// Ensure data is flushed to disk
|
|
651
|
+
await fileHandle.sync();
|
|
652
|
+
// Close the file before rename
|
|
653
|
+
await fileHandle.close();
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
// Clean up if something went wrong
|
|
657
|
+
if (fileHandle) {
|
|
658
|
+
await fileHandle.close().catch(() => { });
|
|
244
659
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
660
|
+
throw error; // Re-throw the original error
|
|
661
|
+
}
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Create a temporary filename in the same directory
|
|
665
|
+
const dir = path.dirname(file);
|
|
666
|
+
const filename = path.basename(file);
|
|
667
|
+
const tmpFile = path.join(dir, `${tmpPrefix}${crypto.randomBytes(6).toString("hex")}-${filename}`);
|
|
668
|
+
try {
|
|
669
|
+
// Create and write to temporary file
|
|
670
|
+
fileHandle = await fs.open(tmpFile, "w", mode);
|
|
671
|
+
// Write data to the temp file
|
|
672
|
+
await fileHandle.writeFile(data, { encoding });
|
|
673
|
+
// Ensure data is flushed to disk
|
|
674
|
+
await fileHandle.sync();
|
|
675
|
+
// Close the file before rename
|
|
676
|
+
await fileHandle.close();
|
|
677
|
+
fileHandle = null;
|
|
678
|
+
// Atomically replace the target file with our temp file
|
|
679
|
+
await fs.rename(tmpFile, file);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
// Clean up if something went wrong
|
|
683
|
+
if (fileHandle) {
|
|
684
|
+
await fileHandle.close().catch(() => { });
|
|
685
|
+
}
|
|
686
|
+
// Try to remove the temporary file
|
|
687
|
+
try {
|
|
688
|
+
await fs.unlink(tmpFile).catch(() => { });
|
|
689
|
+
}
|
|
690
|
+
catch (_) {
|
|
691
|
+
// Ignore errors during cleanup
|
|
692
|
+
}
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
var _a;
|
|
698
|
+
const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
|
|
699
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
|
|
700
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
|
|
701
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
|
|
702
|
+
const PERSIST_BASE_METHOD_NAME_CTOR = "PersistBase.CTOR";
|
|
703
|
+
const PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT = "PersistBase.waitForInit";
|
|
704
|
+
const PERSIST_BASE_METHOD_NAME_READ_VALUE = "PersistBase.readValue";
|
|
705
|
+
const PERSIST_BASE_METHOD_NAME_WRITE_VALUE = "PersistBase.writeValue";
|
|
706
|
+
const PERSIST_BASE_METHOD_NAME_HAS_VALUE = "PersistBase.hasValue";
|
|
707
|
+
const PERSIST_BASE_METHOD_NAME_REMOVE_VALUE = "PersistBase.removeValue";
|
|
708
|
+
const PERSIST_BASE_METHOD_NAME_REMOVE_ALL = "PersistBase.removeAll";
|
|
709
|
+
const PERSIST_BASE_METHOD_NAME_VALUES = "PersistBase.values";
|
|
710
|
+
const PERSIST_BASE_METHOD_NAME_KEYS = "PersistBase.keys";
|
|
711
|
+
const BASE_WAIT_FOR_INIT_FN_METHOD_NAME = "PersistBase.waitForInitFn";
|
|
712
|
+
const BASE_UNLINK_RETRY_COUNT = 5;
|
|
713
|
+
const BASE_UNLINK_RETRY_DELAY = 1000;
|
|
714
|
+
const BASE_WAIT_FOR_INIT_FN = async (self) => {
|
|
715
|
+
backtest$1.loggerService.debug(BASE_WAIT_FOR_INIT_FN_METHOD_NAME, {
|
|
716
|
+
entityName: self.entityName,
|
|
717
|
+
directory: self._directory,
|
|
718
|
+
});
|
|
719
|
+
await fs.mkdir(self._directory, { recursive: true });
|
|
720
|
+
for await (const key of self.keys()) {
|
|
721
|
+
try {
|
|
722
|
+
await self.readValue(key);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
const filePath = self._getFilePath(key);
|
|
726
|
+
console.error(`backtest-kit PersistBase found invalid document for filePath=${filePath} entityName=${self.entityName}`);
|
|
727
|
+
if (await not(BASE_WAIT_FOR_INIT_UNLINK_FN(filePath))) {
|
|
728
|
+
console.error(`backtest-kit PersistBase failed to remove invalid document for filePath=${filePath} entityName=${self.entityName}`);
|
|
262
729
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
const BASE_WAIT_FOR_INIT_UNLINK_FN = async (filePath) => trycatch(retry(async () => {
|
|
734
|
+
try {
|
|
735
|
+
await fs.unlink(filePath);
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${getErrorMessage(error)}`);
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
}, BASE_UNLINK_RETRY_COUNT, BASE_UNLINK_RETRY_DELAY), {
|
|
743
|
+
defaultValue: false,
|
|
744
|
+
});
|
|
745
|
+
/**
|
|
746
|
+
* Base class for file-based persistence with atomic writes.
|
|
747
|
+
*
|
|
748
|
+
* Features:
|
|
749
|
+
* - Atomic file writes using writeFileAtomic
|
|
750
|
+
* - Auto-validation and cleanup of corrupted files
|
|
751
|
+
* - Async generator support for iteration
|
|
752
|
+
* - Retry logic for file deletion
|
|
753
|
+
*
|
|
754
|
+
* @example
|
|
755
|
+
* ```typescript
|
|
756
|
+
* const persist = new PersistBase("my-entity", "./data");
|
|
757
|
+
* await persist.waitForInit(true);
|
|
758
|
+
* await persist.writeValue("key1", { data: "value" });
|
|
759
|
+
* const value = await persist.readValue("key1");
|
|
760
|
+
* ```
|
|
761
|
+
*/
|
|
762
|
+
const PersistBase = makeExtendable(class {
|
|
763
|
+
/**
|
|
764
|
+
* Creates new persistence instance.
|
|
765
|
+
*
|
|
766
|
+
* @param entityName - Unique entity type identifier
|
|
767
|
+
* @param baseDir - Base directory for all entities (default: ./logs/data)
|
|
768
|
+
*/
|
|
769
|
+
constructor(entityName, baseDir = join(process.cwd(), "logs/data")) {
|
|
770
|
+
this.entityName = entityName;
|
|
771
|
+
this.baseDir = baseDir;
|
|
772
|
+
this[_a] = singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
|
|
773
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
|
|
774
|
+
entityName: this.entityName,
|
|
775
|
+
baseDir,
|
|
776
|
+
});
|
|
777
|
+
this._directory = join(this.baseDir, this.entityName);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Computes file path for entity ID.
|
|
781
|
+
*
|
|
782
|
+
* @param entityId - Entity identifier
|
|
783
|
+
* @returns Full file path to entity JSON file
|
|
784
|
+
*/
|
|
785
|
+
_getFilePath(entityId) {
|
|
786
|
+
return join(this.baseDir, this.entityName, `${entityId}.json`);
|
|
787
|
+
}
|
|
788
|
+
async waitForInit(initial) {
|
|
789
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT, {
|
|
790
|
+
entityName: this.entityName,
|
|
791
|
+
initial,
|
|
792
|
+
});
|
|
793
|
+
await this[BASE_WAIT_FOR_INIT_SYMBOL]();
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Returns count of persisted entities.
|
|
797
|
+
*
|
|
798
|
+
* @returns Promise resolving to number of .json files in directory
|
|
799
|
+
*/
|
|
800
|
+
async getCount() {
|
|
801
|
+
const files = await fs.readdir(this._directory);
|
|
802
|
+
const { length } = files.filter((file) => file.endsWith(".json"));
|
|
803
|
+
return length;
|
|
804
|
+
}
|
|
805
|
+
async readValue(entityId) {
|
|
806
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_READ_VALUE, {
|
|
807
|
+
entityName: this.entityName,
|
|
808
|
+
entityId,
|
|
809
|
+
});
|
|
810
|
+
try {
|
|
811
|
+
const filePath = this._getFilePath(entityId);
|
|
812
|
+
const fileContent = await fs.readFile(filePath, "utf-8");
|
|
813
|
+
return JSON.parse(fileContent);
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
if (error?.code === "ENOENT") {
|
|
817
|
+
throw new Error(`Entity ${this.entityName}:${entityId} not found`);
|
|
273
818
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
819
|
+
throw new Error(`Failed to read entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async hasValue(entityId) {
|
|
823
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_HAS_VALUE, {
|
|
824
|
+
entityName: this.entityName,
|
|
825
|
+
entityId,
|
|
826
|
+
});
|
|
827
|
+
try {
|
|
828
|
+
const filePath = this._getFilePath(entityId);
|
|
829
|
+
await fs.access(filePath);
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
if (error?.code === "ENOENT") {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
throw new Error(`Failed to check existence of entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async writeValue(entityId, entity) {
|
|
840
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_WRITE_VALUE, {
|
|
841
|
+
entityName: this.entityName,
|
|
842
|
+
entityId,
|
|
843
|
+
});
|
|
844
|
+
try {
|
|
845
|
+
const filePath = this._getFilePath(entityId);
|
|
846
|
+
const serializedData = JSON.stringify(entity);
|
|
847
|
+
await writeFileAtomic(filePath, serializedData, "utf-8");
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
throw new Error(`Failed to write entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Removes entity from storage.
|
|
855
|
+
*
|
|
856
|
+
* @param entityId - Entity identifier to remove
|
|
857
|
+
* @returns Promise that resolves when entity is deleted
|
|
858
|
+
* @throws Error if entity not found or deletion fails
|
|
859
|
+
*/
|
|
860
|
+
async removeValue(entityId) {
|
|
861
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_REMOVE_VALUE, {
|
|
862
|
+
entityName: this.entityName,
|
|
863
|
+
entityId,
|
|
864
|
+
});
|
|
865
|
+
try {
|
|
866
|
+
const filePath = this._getFilePath(entityId);
|
|
867
|
+
await fs.unlink(filePath);
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
if (error?.code === "ENOENT") {
|
|
871
|
+
throw new Error(`Entity ${this.entityName}:${entityId} not found for deletion`);
|
|
872
|
+
}
|
|
873
|
+
throw new Error(`Failed to remove entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Removes all entities from storage.
|
|
878
|
+
*
|
|
879
|
+
* @returns Promise that resolves when all entities are deleted
|
|
880
|
+
* @throws Error if deletion fails
|
|
881
|
+
*/
|
|
882
|
+
async removeAll() {
|
|
883
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_REMOVE_ALL, {
|
|
884
|
+
entityName: this.entityName,
|
|
885
|
+
});
|
|
886
|
+
try {
|
|
887
|
+
const files = await fs.readdir(this._directory);
|
|
888
|
+
const entityFiles = files.filter((file) => file.endsWith(".json"));
|
|
889
|
+
for (const file of entityFiles) {
|
|
890
|
+
await fs.unlink(join(this._directory, file));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
throw new Error(`Failed to remove values for ${this.entityName}: ${getErrorMessage(error)}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Async generator yielding all entity values.
|
|
899
|
+
* Sorted alphanumerically by entity ID.
|
|
900
|
+
*
|
|
901
|
+
* @returns AsyncGenerator yielding entities
|
|
902
|
+
* @throws Error if reading fails
|
|
903
|
+
*/
|
|
904
|
+
async *values() {
|
|
905
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_VALUES, {
|
|
906
|
+
entityName: this.entityName,
|
|
907
|
+
});
|
|
908
|
+
try {
|
|
909
|
+
const files = await fs.readdir(this._directory);
|
|
910
|
+
const entityIds = files
|
|
911
|
+
.filter((file) => file.endsWith(".json"))
|
|
912
|
+
.map((file) => file.slice(0, -5))
|
|
913
|
+
.sort((a, b) => a.localeCompare(b, undefined, {
|
|
914
|
+
numeric: true,
|
|
915
|
+
sensitivity: "base",
|
|
916
|
+
}));
|
|
917
|
+
for (const entityId of entityIds) {
|
|
918
|
+
const entity = await this.readValue(entityId);
|
|
919
|
+
yield entity;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
throw new Error(`Failed to read values for ${this.entityName}: ${getErrorMessage(error)}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Async generator yielding all entity IDs.
|
|
928
|
+
* Sorted alphanumerically.
|
|
929
|
+
*
|
|
930
|
+
* @returns AsyncGenerator yielding entity IDs
|
|
931
|
+
* @throws Error if reading fails
|
|
932
|
+
*/
|
|
933
|
+
async *keys() {
|
|
934
|
+
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_KEYS, {
|
|
935
|
+
entityName: this.entityName,
|
|
936
|
+
});
|
|
937
|
+
try {
|
|
938
|
+
const files = await fs.readdir(this._directory);
|
|
939
|
+
const entityIds = files
|
|
940
|
+
.filter((file) => file.endsWith(".json"))
|
|
941
|
+
.map((file) => file.slice(0, -5))
|
|
942
|
+
.sort((a, b) => a.localeCompare(b, undefined, {
|
|
943
|
+
numeric: true,
|
|
944
|
+
sensitivity: "base",
|
|
945
|
+
}));
|
|
946
|
+
for (const entityId of entityIds) {
|
|
947
|
+
yield entityId;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
throw new Error(`Failed to read keys for ${this.entityName}: ${getErrorMessage(error)}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Async iterator implementation.
|
|
956
|
+
* Delegates to values() generator.
|
|
957
|
+
*
|
|
958
|
+
* @returns AsyncIterableIterator yielding entities
|
|
959
|
+
*/
|
|
960
|
+
async *[(_a = BASE_WAIT_FOR_INIT_SYMBOL, Symbol.asyncIterator)]() {
|
|
961
|
+
for await (const entity of this.values()) {
|
|
962
|
+
yield entity;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Filters entities by predicate function.
|
|
967
|
+
*
|
|
968
|
+
* @param predicate - Filter function
|
|
969
|
+
* @returns AsyncGenerator yielding filtered entities
|
|
970
|
+
*/
|
|
971
|
+
async *filter(predicate) {
|
|
972
|
+
for await (const entity of this.values()) {
|
|
973
|
+
if (predicate(entity)) {
|
|
974
|
+
yield entity;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Takes first N entities, optionally filtered.
|
|
980
|
+
*
|
|
981
|
+
* @param total - Maximum number of entities to yield
|
|
982
|
+
* @param predicate - Optional filter function
|
|
983
|
+
* @returns AsyncGenerator yielding up to total entities
|
|
984
|
+
*/
|
|
985
|
+
async *take(total, predicate) {
|
|
986
|
+
let count = 0;
|
|
987
|
+
if (predicate) {
|
|
988
|
+
for await (const entity of this.values()) {
|
|
989
|
+
if (!predicate(entity)) {
|
|
990
|
+
continue;
|
|
279
991
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
992
|
+
count += 1;
|
|
993
|
+
yield entity;
|
|
994
|
+
if (count >= total) {
|
|
995
|
+
break;
|
|
283
996
|
}
|
|
284
997
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
priceClose: averagePrice,
|
|
293
|
-
pnlPercentage: pnl.pnlPercentage,
|
|
294
|
-
});
|
|
295
|
-
if (this.params.callbacks?.onClose) {
|
|
296
|
-
this.params.callbacks.onClose(this.params.execution.context.backtest, symbol, averagePrice, signal);
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
for await (const entity of this.values()) {
|
|
1001
|
+
count += 1;
|
|
1002
|
+
yield entity;
|
|
1003
|
+
if (count >= total) {
|
|
1004
|
+
break;
|
|
297
1005
|
}
|
|
298
|
-
this._pendingSignal = null;
|
|
299
|
-
return {
|
|
300
|
-
action: "closed",
|
|
301
|
-
signal: signal,
|
|
302
|
-
currentPrice: averagePrice,
|
|
303
|
-
closeReason: closeReason,
|
|
304
|
-
pnl: pnl,
|
|
305
|
-
};
|
|
306
1006
|
}
|
|
307
|
-
|
|
308
|
-
action: "active",
|
|
309
|
-
signal: signal,
|
|
310
|
-
currentPrice: averagePrice,
|
|
311
|
-
};
|
|
312
|
-
};
|
|
1007
|
+
}
|
|
313
1008
|
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
class
|
|
1009
|
+
});
|
|
1010
|
+
/**
|
|
1011
|
+
* Utility class for managing signal persistence.
|
|
1012
|
+
*
|
|
1013
|
+
* Features:
|
|
1014
|
+
* - Memoized storage instances per strategy
|
|
1015
|
+
* - Custom adapter support
|
|
1016
|
+
* - Atomic read/write operations
|
|
1017
|
+
* - Crash-safe signal state management
|
|
1018
|
+
*
|
|
1019
|
+
* Used by ClientStrategy for live mode persistence.
|
|
1020
|
+
*/
|
|
1021
|
+
class PersistSignalUtils {
|
|
317
1022
|
constructor() {
|
|
318
|
-
this.
|
|
319
|
-
this.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
this.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1023
|
+
this.PersistSignalFactory = PersistBase;
|
|
1024
|
+
this.getSignalStorage = memoize(([strategyName]) => `${strategyName}`, (strategyName) => Reflect.construct(this.PersistSignalFactory, [
|
|
1025
|
+
strategyName,
|
|
1026
|
+
`./logs/data/signal/`,
|
|
1027
|
+
]));
|
|
1028
|
+
/**
|
|
1029
|
+
* Reads persisted signal data for a strategy and symbol.
|
|
1030
|
+
*
|
|
1031
|
+
* Called by ClientStrategy.waitForInit() to restore state.
|
|
1032
|
+
* Returns null if no signal exists.
|
|
1033
|
+
*
|
|
1034
|
+
* @param strategyName - Strategy identifier
|
|
1035
|
+
* @param symbol - Trading pair symbol
|
|
1036
|
+
* @returns Promise resolving to signal or null
|
|
1037
|
+
*/
|
|
1038
|
+
this.readSignalData = async (strategyName, symbol) => {
|
|
1039
|
+
backtest$1.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA);
|
|
1040
|
+
const isInitial = !this.getSignalStorage.has(strategyName);
|
|
1041
|
+
const stateStorage = this.getSignalStorage(strategyName);
|
|
1042
|
+
await stateStorage.waitForInit(isInitial);
|
|
1043
|
+
if (await stateStorage.hasValue(symbol)) {
|
|
1044
|
+
const { signalRow } = await stateStorage.readValue(symbol);
|
|
1045
|
+
return signalRow;
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
338
1048
|
};
|
|
1049
|
+
/**
|
|
1050
|
+
* Writes signal data to disk with atomic file writes.
|
|
1051
|
+
*
|
|
1052
|
+
* Called by ClientStrategy.setPendingSignal() to persist state.
|
|
1053
|
+
* Uses atomic writes to prevent corruption on crashes.
|
|
1054
|
+
*
|
|
1055
|
+
* @param signalRow - Signal data (null to clear)
|
|
1056
|
+
* @param strategyName - Strategy identifier
|
|
1057
|
+
* @param symbol - Trading pair symbol
|
|
1058
|
+
* @returns Promise that resolves when write is complete
|
|
1059
|
+
*/
|
|
1060
|
+
this.writeSignalData = async (signalRow, strategyName, symbol) => {
|
|
1061
|
+
backtest$1.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA);
|
|
1062
|
+
const isInitial = !this.getSignalStorage.has(strategyName);
|
|
1063
|
+
const stateStorage = this.getSignalStorage(strategyName);
|
|
1064
|
+
await stateStorage.waitForInit(isInitial);
|
|
1065
|
+
await stateStorage.writeValue(symbol, { signalRow });
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Registers a custom persistence adapter.
|
|
1070
|
+
*
|
|
1071
|
+
* @param Ctor - Custom PersistBase constructor
|
|
1072
|
+
*
|
|
1073
|
+
* @example
|
|
1074
|
+
* ```typescript
|
|
1075
|
+
* class RedisPersist extends PersistBase {
|
|
1076
|
+
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
1077
|
+
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
1078
|
+
* }
|
|
1079
|
+
* PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
|
|
1080
|
+
* ```
|
|
1081
|
+
*/
|
|
1082
|
+
usePersistSignalAdapter(Ctor) {
|
|
1083
|
+
backtest$1.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER);
|
|
1084
|
+
this.PersistSignalFactory = Ctor;
|
|
339
1085
|
}
|
|
340
1086
|
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Global singleton instance of PersistSignalUtils.
|
|
1089
|
+
* Used by ClientStrategy for signal persistence.
|
|
1090
|
+
*
|
|
1091
|
+
* @example
|
|
1092
|
+
* ```typescript
|
|
1093
|
+
* // Custom adapter
|
|
1094
|
+
* PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
|
|
1095
|
+
*
|
|
1096
|
+
* // Read signal
|
|
1097
|
+
* const signal = await PersistSignalAdaper.readSignalData("my-strategy", "BTCUSDT");
|
|
1098
|
+
*
|
|
1099
|
+
* // Write signal
|
|
1100
|
+
* await PersistSignalAdaper.writeSignalData(signal, "my-strategy", "BTCUSDT");
|
|
1101
|
+
* ```
|
|
1102
|
+
*/
|
|
1103
|
+
const PersistSignalAdaper = new PersistSignalUtils();
|
|
341
1104
|
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
1105
|
+
const INTERVAL_MINUTES$1 = {
|
|
1106
|
+
"1m": 1,
|
|
1107
|
+
"3m": 3,
|
|
1108
|
+
"5m": 5,
|
|
1109
|
+
"15m": 15,
|
|
1110
|
+
"30m": 30,
|
|
1111
|
+
"1h": 60,
|
|
1112
|
+
};
|
|
1113
|
+
const VALIDATE_SIGNAL_FN = (signal) => {
|
|
1114
|
+
const errors = [];
|
|
1115
|
+
// Валидация цен
|
|
1116
|
+
if (signal.priceOpen <= 0) {
|
|
1117
|
+
errors.push(`priceOpen must be positive, got ${signal.priceOpen}`);
|
|
1118
|
+
}
|
|
1119
|
+
if (signal.priceTakeProfit <= 0) {
|
|
1120
|
+
errors.push(`priceTakeProfit must be positive, got ${signal.priceTakeProfit}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (signal.priceStopLoss <= 0) {
|
|
1123
|
+
errors.push(`priceStopLoss must be positive, got ${signal.priceStopLoss}`);
|
|
1124
|
+
}
|
|
1125
|
+
// Валидация для long позиции
|
|
1126
|
+
if (signal.position === "long") {
|
|
1127
|
+
if (signal.priceTakeProfit <= signal.priceOpen) {
|
|
1128
|
+
errors.push(`Long: priceTakeProfit (${signal.priceTakeProfit}) must be > priceOpen (${signal.priceOpen})`);
|
|
1129
|
+
}
|
|
1130
|
+
if (signal.priceStopLoss >= signal.priceOpen) {
|
|
1131
|
+
errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Валидация для short позиции
|
|
1135
|
+
if (signal.position === "short") {
|
|
1136
|
+
if (signal.priceTakeProfit >= signal.priceOpen) {
|
|
1137
|
+
errors.push(`Short: priceTakeProfit (${signal.priceTakeProfit}) must be < priceOpen (${signal.priceOpen})`);
|
|
1138
|
+
}
|
|
1139
|
+
if (signal.priceStopLoss <= signal.priceOpen) {
|
|
1140
|
+
errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
// Валидация временных параметров
|
|
1144
|
+
if (signal.minuteEstimatedTime <= 0) {
|
|
1145
|
+
errors.push(`minuteEstimatedTime must be positive, got ${signal.minuteEstimatedTime}`);
|
|
1146
|
+
}
|
|
1147
|
+
if (signal.timestamp <= 0) {
|
|
1148
|
+
errors.push(`timestamp must be positive, got ${signal.timestamp}`);
|
|
1149
|
+
}
|
|
1150
|
+
// Кидаем ошибку если есть проблемы
|
|
1151
|
+
if (errors.length > 0) {
|
|
1152
|
+
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
const GET_SIGNAL_FN = trycatch(async (self) => {
|
|
1156
|
+
const currentTime = self.params.execution.context.when.getTime();
|
|
1157
|
+
{
|
|
1158
|
+
const intervalMinutes = INTERVAL_MINUTES$1[self.params.interval];
|
|
1159
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
1160
|
+
// Проверяем что прошел нужный интервал с последнего getSignal
|
|
1161
|
+
if (self._lastSignalTimestamp !== null &&
|
|
1162
|
+
currentTime - self._lastSignalTimestamp < intervalMs) {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
self._lastSignalTimestamp = currentTime;
|
|
1166
|
+
}
|
|
1167
|
+
const signal = await self.params.getSignal(self.params.execution.context.symbol);
|
|
1168
|
+
if (!signal) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
const signalRow = {
|
|
1172
|
+
id: randomString(),
|
|
1173
|
+
...signal,
|
|
1174
|
+
symbol: self.params.execution.context.symbol,
|
|
1175
|
+
exchangeName: self.params.method.context.exchangeName,
|
|
1176
|
+
strategyName: self.params.method.context.strategyName,
|
|
1177
|
+
timestamp: currentTime,
|
|
1178
|
+
};
|
|
1179
|
+
// Валидируем сигнал перед возвратом
|
|
1180
|
+
VALIDATE_SIGNAL_FN(signalRow);
|
|
1181
|
+
return signalRow;
|
|
1182
|
+
}, {
|
|
1183
|
+
defaultValue: null,
|
|
1184
|
+
});
|
|
1185
|
+
const GET_AVG_PRICE_FN = (candles) => {
|
|
1186
|
+
const sumPriceVolume = candles.reduce((acc, c) => {
|
|
1187
|
+
const typicalPrice = (c.high + c.low + c.close) / 3;
|
|
1188
|
+
return acc + typicalPrice * c.volume;
|
|
1189
|
+
}, 0);
|
|
1190
|
+
const totalVolume = candles.reduce((acc, c) => acc + c.volume, 0);
|
|
1191
|
+
return totalVolume === 0
|
|
1192
|
+
? candles.reduce((acc, c) => acc + c.close, 0) / candles.length
|
|
1193
|
+
: sumPriceVolume / totalVolume;
|
|
1194
|
+
};
|
|
1195
|
+
const WAIT_FOR_INIT_FN = async (self) => {
|
|
1196
|
+
self.params.logger.debug("ClientStrategy waitForInit");
|
|
1197
|
+
if (self.params.execution.context.backtest) {
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const pendingSignal = await PersistSignalAdaper.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
|
|
1201
|
+
if (!pendingSignal) {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (pendingSignal.exchangeName !== self.params.method.context.exchangeName) {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (pendingSignal.strategyName !== self.params.method.context.strategyName) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
self._pendingSignal = pendingSignal;
|
|
1211
|
+
};
|
|
1212
|
+
/**
|
|
1213
|
+
* Client implementation for trading strategy lifecycle management.
|
|
1214
|
+
*
|
|
1215
|
+
* Features:
|
|
1216
|
+
* - Signal generation with interval throttling
|
|
1217
|
+
* - Automatic signal validation (prices, TP/SL logic, timestamps)
|
|
1218
|
+
* - Crash-safe persistence in live mode
|
|
1219
|
+
* - VWAP-based TP/SL monitoring
|
|
1220
|
+
* - Fast backtest with candle array processing
|
|
1221
|
+
*
|
|
1222
|
+
* All methods use prototype functions for memory efficiency.
|
|
1223
|
+
*
|
|
1224
|
+
* @example
|
|
1225
|
+
* ```typescript
|
|
1226
|
+
* const strategy = new ClientStrategy({
|
|
1227
|
+
* strategyName: "my-strategy",
|
|
1228
|
+
* interval: "5m",
|
|
1229
|
+
* getSignal: async (symbol) => ({ ... }),
|
|
1230
|
+
* execution: executionService,
|
|
1231
|
+
* exchange: exchangeService,
|
|
1232
|
+
* logger: loggerService,
|
|
1233
|
+
* });
|
|
1234
|
+
*
|
|
1235
|
+
* await strategy.waitForInit(); // Load persisted state
|
|
1236
|
+
* const result = await strategy.tick(); // Monitor signal
|
|
1237
|
+
* ```
|
|
1238
|
+
*/
|
|
1239
|
+
class ClientStrategy {
|
|
1240
|
+
constructor(params) {
|
|
1241
|
+
this.params = params;
|
|
1242
|
+
this._pendingSignal = null;
|
|
1243
|
+
this._lastSignalTimestamp = null;
|
|
1244
|
+
/**
|
|
1245
|
+
* Initializes strategy state by loading persisted signal from disk.
|
|
1246
|
+
*
|
|
1247
|
+
* Uses singleshot pattern to ensure initialization happens exactly once.
|
|
1248
|
+
* In backtest mode: skips persistence, no state to load
|
|
1249
|
+
* In live mode: reads last signal state from disk
|
|
1250
|
+
*
|
|
1251
|
+
* @returns Promise that resolves when initialization is complete
|
|
1252
|
+
*/
|
|
1253
|
+
this.waitForInit = singleshot(async () => await WAIT_FOR_INIT_FN(this));
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Updates pending signal and persists to disk in live mode.
|
|
1257
|
+
*
|
|
1258
|
+
* Centralized method for all signal state changes.
|
|
1259
|
+
* Uses atomic file writes to prevent corruption.
|
|
1260
|
+
*
|
|
1261
|
+
* @param pendingSignal - New signal state (null to clear)
|
|
1262
|
+
* @returns Promise that resolves when update is complete
|
|
1263
|
+
*/
|
|
1264
|
+
async setPendingSignal(pendingSignal) {
|
|
1265
|
+
this.params.logger.debug("ClientStrategy setPendingSignal", {
|
|
1266
|
+
pendingSignal,
|
|
1267
|
+
});
|
|
1268
|
+
this._pendingSignal = pendingSignal;
|
|
1269
|
+
if (this.params.execution.context.backtest) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
await PersistSignalAdaper.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Performs a single tick of strategy execution.
|
|
1276
|
+
*
|
|
1277
|
+
* Flow:
|
|
1278
|
+
* 1. If no pending signal: call getSignal with throttling and validation
|
|
1279
|
+
* 2. If signal opened: trigger onOpen callback, persist state
|
|
1280
|
+
* 3. If pending signal exists: check VWAP against TP/SL
|
|
1281
|
+
* 4. If TP/SL/time reached: close signal, trigger onClose, persist state
|
|
1282
|
+
*
|
|
1283
|
+
* @returns Promise resolving to discriminated union result:
|
|
1284
|
+
* - idle: No signal generated
|
|
1285
|
+
* - opened: New signal just created
|
|
1286
|
+
* - active: Signal monitoring in progress
|
|
1287
|
+
* - closed: Signal completed with PNL
|
|
1288
|
+
*
|
|
1289
|
+
* @example
|
|
1290
|
+
* ```typescript
|
|
1291
|
+
* const result = await strategy.tick();
|
|
1292
|
+
* if (result.action === "closed") {
|
|
1293
|
+
* console.log(`PNL: ${result.pnl.pnlPercentage}%`);
|
|
1294
|
+
* }
|
|
1295
|
+
* ```
|
|
1296
|
+
*/
|
|
1297
|
+
async tick() {
|
|
1298
|
+
this.params.logger.debug("ClientStrategy tick");
|
|
1299
|
+
if (!this._pendingSignal) {
|
|
1300
|
+
const pendingSignal = await GET_SIGNAL_FN(this);
|
|
1301
|
+
await this.setPendingSignal(pendingSignal);
|
|
1302
|
+
if (this._pendingSignal) {
|
|
1303
|
+
if (this.params.callbacks?.onOpen) {
|
|
1304
|
+
this.params.callbacks.onOpen(this.params.execution.context.symbol, this._pendingSignal, this._pendingSignal.priceOpen, this.params.execution.context.backtest);
|
|
1305
|
+
}
|
|
1306
|
+
const result = {
|
|
1307
|
+
action: "opened",
|
|
1308
|
+
signal: this._pendingSignal,
|
|
1309
|
+
strategyName: this.params.method.context.strategyName,
|
|
1310
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
1311
|
+
currentPrice: this._pendingSignal.priceOpen,
|
|
1312
|
+
};
|
|
1313
|
+
if (this.params.callbacks?.onTick) {
|
|
1314
|
+
this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
|
|
1315
|
+
}
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
|
|
1319
|
+
if (this.params.callbacks?.onIdle) {
|
|
1320
|
+
this.params.callbacks.onIdle(this.params.execution.context.symbol, currentPrice, this.params.execution.context.backtest);
|
|
1321
|
+
}
|
|
1322
|
+
const result = {
|
|
1323
|
+
action: "idle",
|
|
1324
|
+
signal: null,
|
|
1325
|
+
strategyName: this.params.method.context.strategyName,
|
|
1326
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
1327
|
+
currentPrice,
|
|
1328
|
+
};
|
|
1329
|
+
if (this.params.callbacks?.onTick) {
|
|
1330
|
+
this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
|
|
1331
|
+
}
|
|
1332
|
+
return result;
|
|
1333
|
+
}
|
|
1334
|
+
const when = this.params.execution.context.when;
|
|
1335
|
+
const signal = this._pendingSignal;
|
|
1336
|
+
// Получаем среднюю цену
|
|
1337
|
+
const averagePrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
|
|
1338
|
+
this.params.logger.debug("ClientStrategy tick check", {
|
|
1339
|
+
symbol: this.params.execution.context.symbol,
|
|
1340
|
+
averagePrice,
|
|
1341
|
+
signalId: signal.id,
|
|
1342
|
+
position: signal.position,
|
|
1343
|
+
});
|
|
1344
|
+
let shouldClose = false;
|
|
1345
|
+
let closeReason;
|
|
1346
|
+
// Проверяем истечение времени
|
|
1347
|
+
const signalEndTime = signal.timestamp + signal.minuteEstimatedTime * 60 * 1000;
|
|
1348
|
+
if (when.getTime() >= signalEndTime) {
|
|
1349
|
+
shouldClose = true;
|
|
1350
|
+
closeReason = "time_expired";
|
|
1351
|
+
}
|
|
1352
|
+
// Проверяем достижение TP/SL для long позиции
|
|
1353
|
+
if (signal.position === "long") {
|
|
1354
|
+
if (averagePrice >= signal.priceTakeProfit) {
|
|
1355
|
+
shouldClose = true;
|
|
1356
|
+
closeReason = "take_profit";
|
|
1357
|
+
}
|
|
1358
|
+
else if (averagePrice <= signal.priceStopLoss) {
|
|
1359
|
+
shouldClose = true;
|
|
1360
|
+
closeReason = "stop_loss";
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// Проверяем достижение TP/SL для short позиции
|
|
1364
|
+
if (signal.position === "short") {
|
|
1365
|
+
if (averagePrice <= signal.priceTakeProfit) {
|
|
1366
|
+
shouldClose = true;
|
|
1367
|
+
closeReason = "take_profit";
|
|
1368
|
+
}
|
|
1369
|
+
else if (averagePrice >= signal.priceStopLoss) {
|
|
1370
|
+
shouldClose = true;
|
|
1371
|
+
closeReason = "stop_loss";
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
// Закрываем сигнал если выполнены условия
|
|
1375
|
+
if (shouldClose) {
|
|
1376
|
+
const pnl = toProfitLossDto(signal, averagePrice);
|
|
1377
|
+
const closeTimestamp = this.params.execution.context.when.getTime();
|
|
1378
|
+
// Предупреждение о закрытии сигнала в убыток
|
|
1379
|
+
if (closeReason === "stop_loss") {
|
|
1380
|
+
this.params.logger.warn(`ClientStrategy tick: Signal closed with loss (stop_loss), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
|
|
1381
|
+
}
|
|
1382
|
+
// Предупреждение о закрытии сигнала в убыток
|
|
1383
|
+
if (closeReason === "time_expired" && pnl.pnlPercentage < 0) {
|
|
1384
|
+
this.params.logger.warn(`ClientStrategy tick: Signal closed with loss (time_expired), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
|
|
1385
|
+
}
|
|
1386
|
+
this.params.logger.debug("ClientStrategy closing", {
|
|
1387
|
+
symbol: this.params.execution.context.symbol,
|
|
1388
|
+
signalId: signal.id,
|
|
1389
|
+
reason: closeReason,
|
|
1390
|
+
priceClose: averagePrice,
|
|
1391
|
+
closeTimestamp,
|
|
1392
|
+
pnlPercentage: pnl.pnlPercentage,
|
|
1393
|
+
});
|
|
1394
|
+
if (this.params.callbacks?.onClose) {
|
|
1395
|
+
this.params.callbacks.onClose(this.params.execution.context.symbol, signal, averagePrice, this.params.execution.context.backtest);
|
|
1396
|
+
}
|
|
1397
|
+
await this.setPendingSignal(null);
|
|
1398
|
+
const result = {
|
|
1399
|
+
action: "closed",
|
|
1400
|
+
signal: signal,
|
|
1401
|
+
currentPrice: averagePrice,
|
|
1402
|
+
closeReason: closeReason,
|
|
1403
|
+
closeTimestamp: closeTimestamp,
|
|
1404
|
+
pnl: pnl,
|
|
1405
|
+
strategyName: this.params.method.context.strategyName,
|
|
1406
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
1407
|
+
};
|
|
1408
|
+
if (this.params.callbacks?.onTick) {
|
|
1409
|
+
this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
|
|
1410
|
+
}
|
|
1411
|
+
return result;
|
|
1412
|
+
}
|
|
1413
|
+
if (this.params.callbacks?.onActive) {
|
|
1414
|
+
this.params.callbacks.onActive(this.params.execution.context.symbol, signal, averagePrice, this.params.execution.context.backtest);
|
|
1415
|
+
}
|
|
1416
|
+
const result = {
|
|
1417
|
+
action: "active",
|
|
1418
|
+
signal: signal,
|
|
1419
|
+
currentPrice: averagePrice,
|
|
1420
|
+
strategyName: this.params.method.context.strategyName,
|
|
1421
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
1422
|
+
};
|
|
1423
|
+
if (this.params.callbacks?.onTick) {
|
|
1424
|
+
this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
|
|
1425
|
+
}
|
|
1426
|
+
return result;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Fast backtests a pending signal using historical candle data.
|
|
1430
|
+
*
|
|
1431
|
+
* Iterates through candles checking VWAP against TP/SL on each timeframe.
|
|
1432
|
+
* Starts from index 4 (needs 5 candles for VWAP calculation).
|
|
1433
|
+
* Always returns closed result (either TP/SL or time_expired).
|
|
1434
|
+
*
|
|
1435
|
+
* @param candles - Array of candles covering signal's minuteEstimatedTime
|
|
1436
|
+
* @returns Promise resolving to closed signal result with PNL
|
|
1437
|
+
* @throws Error if no pending signal or not in backtest mode
|
|
1438
|
+
*
|
|
1439
|
+
* @example
|
|
1440
|
+
* ```typescript
|
|
1441
|
+
* // After signal opened in backtest
|
|
1442
|
+
* const candles = await exchange.getNextCandles("BTCUSDT", "1m", signal.minuteEstimatedTime);
|
|
1443
|
+
* const result = await strategy.backtest(candles);
|
|
1444
|
+
* console.log(result.closeReason); // "take_profit" | "stop_loss" | "time_expired"
|
|
1445
|
+
* ```
|
|
1446
|
+
*/
|
|
1447
|
+
async backtest(candles) {
|
|
1448
|
+
this.params.logger.debug("ClientStrategy backtest", {
|
|
1449
|
+
symbol: this.params.execution.context.symbol,
|
|
1450
|
+
candlesCount: candles.length,
|
|
1451
|
+
});
|
|
1452
|
+
const signal = this._pendingSignal;
|
|
1453
|
+
if (!signal) {
|
|
1454
|
+
throw new Error("ClientStrategy backtest: no pending signal");
|
|
1455
|
+
}
|
|
1456
|
+
if (!this.params.execution.context.backtest) {
|
|
1457
|
+
throw new Error("ClientStrategy backtest: running in live context");
|
|
1458
|
+
}
|
|
1459
|
+
// Предупреждение если недостаточно свечей для VWAP
|
|
1460
|
+
if (candles.length < 5) {
|
|
1461
|
+
this.params.logger.warn(`ClientStrategy backtest: Expected at least 5 candles for VWAP, got ${candles.length}`);
|
|
1462
|
+
}
|
|
1463
|
+
// Проверяем каждую свечу на достижение TP/SL
|
|
1464
|
+
// Начинаем с индекса 4 (пятая свеча), чтобы было минимум 5 свечей для VWAP
|
|
1465
|
+
for (let i = 4; i < candles.length; i++) {
|
|
1466
|
+
// Вычисляем VWAP из последних 5 свечей для текущего момента
|
|
1467
|
+
const recentCandles = candles.slice(i - 4, i + 1);
|
|
1468
|
+
const averagePrice = GET_AVG_PRICE_FN(recentCandles);
|
|
1469
|
+
let shouldClose = false;
|
|
1470
|
+
let closeReason;
|
|
1471
|
+
// Проверяем достижение TP/SL для long позиции
|
|
1472
|
+
if (signal.position === "long") {
|
|
1473
|
+
if (averagePrice >= signal.priceTakeProfit) {
|
|
1474
|
+
shouldClose = true;
|
|
1475
|
+
closeReason = "take_profit";
|
|
1476
|
+
}
|
|
1477
|
+
else if (averagePrice <= signal.priceStopLoss) {
|
|
1478
|
+
shouldClose = true;
|
|
1479
|
+
closeReason = "stop_loss";
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
// Проверяем достижение TP/SL для short позиции
|
|
1483
|
+
if (signal.position === "short") {
|
|
1484
|
+
if (averagePrice <= signal.priceTakeProfit) {
|
|
1485
|
+
shouldClose = true;
|
|
1486
|
+
closeReason = "take_profit";
|
|
1487
|
+
}
|
|
1488
|
+
else if (averagePrice >= signal.priceStopLoss) {
|
|
1489
|
+
shouldClose = true;
|
|
1490
|
+
closeReason = "stop_loss";
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// Если достигнут TP/SL, закрываем сигнал
|
|
1494
|
+
if (shouldClose) {
|
|
1495
|
+
const pnl = toProfitLossDto(signal, averagePrice);
|
|
1496
|
+
const closeTimestamp = recentCandles[recentCandles.length - 1].timestamp;
|
|
1497
|
+
this.params.logger.debug("ClientStrategy backtest closing", {
|
|
1498
|
+
symbol: this.params.execution.context.symbol,
|
|
1499
|
+
signalId: signal.id,
|
|
1500
|
+
reason: closeReason,
|
|
1501
|
+
priceClose: averagePrice,
|
|
1502
|
+
closeTimestamp,
|
|
1503
|
+
pnlPercentage: pnl.pnlPercentage,
|
|
1504
|
+
});
|
|
1505
|
+
// Предупреждение при убытке от stop_loss
|
|
1506
|
+
if (closeReason === "stop_loss") {
|
|
1507
|
+
this.params.logger.warn(`ClientStrategy backtest: Signal closed with loss (stop_loss), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
|
|
1508
|
+
}
|
|
1509
|
+
if (this.params.callbacks?.onClose) {
|
|
1510
|
+
this.params.callbacks.onClose(this.params.execution.context.symbol, signal, averagePrice, this.params.execution.context.backtest);
|
|
1511
|
+
}
|
|
1512
|
+
await this.setPendingSignal(null);
|
|
1513
|
+
const result = {
|
|
1514
|
+
action: "closed",
|
|
1515
|
+
signal: signal,
|
|
1516
|
+
currentPrice: averagePrice,
|
|
1517
|
+
closeReason: closeReason,
|
|
1518
|
+
closeTimestamp: closeTimestamp,
|
|
1519
|
+
pnl: pnl,
|
|
1520
|
+
strategyName: this.params.method.context.strategyName,
|
|
1521
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
1522
|
+
};
|
|
1523
|
+
if (this.params.callbacks?.onTick) {
|
|
1524
|
+
this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
|
|
1525
|
+
}
|
|
1526
|
+
return result;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// Если TP/SL не достигнут за период, вычисляем VWAP из последних 5 свечей
|
|
1530
|
+
const lastFiveCandles = candles.slice(-5);
|
|
1531
|
+
const lastPrice = GET_AVG_PRICE_FN(lastFiveCandles);
|
|
1532
|
+
const closeTimestamp = lastFiveCandles[lastFiveCandles.length - 1].timestamp;
|
|
1533
|
+
const pnl = toProfitLossDto(signal, lastPrice);
|
|
1534
|
+
this.params.logger.debug("ClientStrategy backtest time_expired", {
|
|
1535
|
+
symbol: this.params.execution.context.symbol,
|
|
1536
|
+
signalId: signal.id,
|
|
1537
|
+
priceClose: lastPrice,
|
|
1538
|
+
closeTimestamp,
|
|
1539
|
+
pnlPercentage: pnl.pnlPercentage,
|
|
1540
|
+
});
|
|
1541
|
+
// Предупреждение при убытке от time_expired
|
|
1542
|
+
if (pnl.pnlPercentage < 0) {
|
|
1543
|
+
this.params.logger.warn(`ClientStrategy backtest: Signal closed with loss (time_expired), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
|
|
1544
|
+
}
|
|
1545
|
+
if (this.params.callbacks?.onClose) {
|
|
1546
|
+
this.params.callbacks.onClose(this.params.execution.context.symbol, signal, lastPrice, this.params.execution.context.backtest);
|
|
1547
|
+
}
|
|
1548
|
+
await this.setPendingSignal(null);
|
|
1549
|
+
const result = {
|
|
1550
|
+
action: "closed",
|
|
1551
|
+
signal: signal,
|
|
1552
|
+
currentPrice: lastPrice,
|
|
1553
|
+
closeReason: "time_expired",
|
|
1554
|
+
closeTimestamp: closeTimestamp,
|
|
1555
|
+
pnl: pnl,
|
|
1556
|
+
strategyName: this.params.method.context.strategyName,
|
|
1557
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
1558
|
+
};
|
|
1559
|
+
if (this.params.callbacks?.onTick) {
|
|
1560
|
+
this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
|
|
1561
|
+
}
|
|
1562
|
+
return result;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Global signal emitter for all trading events (live + backtest).
|
|
1568
|
+
* Emits all signal events regardless of execution mode.
|
|
1569
|
+
*/
|
|
1570
|
+
const signalEmitter = new Subject();
|
|
1571
|
+
/**
|
|
1572
|
+
* Live trading signal emitter.
|
|
1573
|
+
* Emits only signals from live trading execution.
|
|
1574
|
+
*/
|
|
1575
|
+
const signalLiveEmitter = new Subject();
|
|
1576
|
+
/**
|
|
1577
|
+
* Backtest signal emitter.
|
|
1578
|
+
* Emits only signals from backtest execution.
|
|
1579
|
+
*/
|
|
1580
|
+
const signalBacktestEmitter = new Subject();
|
|
1581
|
+
/**
|
|
1582
|
+
* Error emitter for background execution errors.
|
|
1583
|
+
* Emits errors caught in background tasks (Live.background, Backtest.background).
|
|
1584
|
+
*/
|
|
1585
|
+
const errorEmitter = new Subject();
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Connection service routing strategy operations to correct ClientStrategy instance.
|
|
1589
|
+
*
|
|
1590
|
+
* Routes all IStrategy method calls to the appropriate strategy implementation
|
|
1591
|
+
* based on methodContextService.context.strategyName. Uses memoization to cache
|
|
1592
|
+
* ClientStrategy instances for performance.
|
|
1593
|
+
*
|
|
1594
|
+
* Key features:
|
|
1595
|
+
* - Automatic strategy routing via method context
|
|
1596
|
+
* - Memoized ClientStrategy instances by strategyName
|
|
1597
|
+
* - Implements IStrategy interface
|
|
1598
|
+
* - Ensures initialization with waitForInit() before operations
|
|
1599
|
+
* - Handles both tick() (live) and backtest() operations
|
|
1600
|
+
*
|
|
1601
|
+
* @example
|
|
1602
|
+
* ```typescript
|
|
1603
|
+
* // Used internally by framework
|
|
1604
|
+
* const result = await strategyConnectionService.tick();
|
|
1605
|
+
* // Automatically routes to correct strategy based on methodContext
|
|
1606
|
+
* ```
|
|
1607
|
+
*/
|
|
1608
|
+
class StrategyConnectionService {
|
|
1609
|
+
constructor() {
|
|
1610
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
1611
|
+
this.executionContextService = inject(TYPES.executionContextService);
|
|
1612
|
+
this.strategySchemaService = inject(TYPES.strategySchemaService);
|
|
1613
|
+
this.exchangeConnectionService = inject(TYPES.exchangeConnectionService);
|
|
1614
|
+
this.methodContextService = inject(TYPES.methodContextService);
|
|
1615
|
+
/**
|
|
1616
|
+
* Retrieves memoized ClientStrategy instance for given strategy name.
|
|
1617
|
+
*
|
|
1618
|
+
* Creates ClientStrategy on first call, returns cached instance on subsequent calls.
|
|
1619
|
+
* Cache key is strategyName string.
|
|
1620
|
+
*
|
|
1621
|
+
* @param strategyName - Name of registered strategy schema
|
|
1622
|
+
* @returns Configured ClientStrategy instance
|
|
1623
|
+
*/
|
|
1624
|
+
this.getStrategy = memoize(([strategyName]) => `${strategyName}`, (strategyName) => {
|
|
1625
|
+
const { getSignal, interval, callbacks } = this.strategySchemaService.get(strategyName);
|
|
1626
|
+
return new ClientStrategy({
|
|
1627
|
+
interval,
|
|
1628
|
+
execution: this.executionContextService,
|
|
1629
|
+
method: this.methodContextService,
|
|
1630
|
+
logger: this.loggerService,
|
|
1631
|
+
exchange: this.exchangeConnectionService,
|
|
1632
|
+
strategyName,
|
|
1633
|
+
getSignal,
|
|
1634
|
+
callbacks,
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
/**
|
|
1638
|
+
* Executes live trading tick for current strategy.
|
|
1639
|
+
*
|
|
1640
|
+
* Waits for strategy initialization before processing tick.
|
|
1641
|
+
* Evaluates current market conditions and returns signal state.
|
|
1642
|
+
*
|
|
1643
|
+
* @returns Promise resolving to tick result (idle, opened, active, closed)
|
|
1644
|
+
*/
|
|
1645
|
+
this.tick = async () => {
|
|
1646
|
+
this.loggerService.log("strategyConnectionService tick");
|
|
1647
|
+
const strategy = await this.getStrategy(this.methodContextService.context.strategyName);
|
|
1648
|
+
await strategy.waitForInit();
|
|
1649
|
+
const tick = await strategy.tick();
|
|
1650
|
+
{
|
|
1651
|
+
if (this.executionContextService.context.backtest) {
|
|
1652
|
+
signalBacktestEmitter.next(tick);
|
|
1653
|
+
}
|
|
1654
|
+
if (!this.executionContextService.context.backtest) {
|
|
1655
|
+
signalLiveEmitter.next(tick);
|
|
1656
|
+
}
|
|
1657
|
+
signalEmitter.next(tick);
|
|
1658
|
+
}
|
|
1659
|
+
return tick;
|
|
1660
|
+
};
|
|
1661
|
+
/**
|
|
1662
|
+
* Executes backtest for current strategy with provided candles.
|
|
1663
|
+
*
|
|
1664
|
+
* Waits for strategy initialization before processing candles.
|
|
1665
|
+
* Evaluates strategy signals against historical data.
|
|
1666
|
+
*
|
|
1667
|
+
* @param candles - Array of historical candle data to backtest
|
|
1668
|
+
* @returns Promise resolving to backtest result (signal or idle)
|
|
1669
|
+
*/
|
|
1670
|
+
this.backtest = async (candles) => {
|
|
1671
|
+
this.loggerService.log("strategyConnectionService backtest", {
|
|
1672
|
+
candleCount: candles.length,
|
|
1673
|
+
});
|
|
1674
|
+
const strategy = await this.getStrategy(this.methodContextService.context.strategyName);
|
|
1675
|
+
await strategy.waitForInit();
|
|
1676
|
+
const tick = await strategy.backtest(candles);
|
|
1677
|
+
{
|
|
1678
|
+
if (this.executionContextService.context.backtest) {
|
|
1679
|
+
signalBacktestEmitter.next(tick);
|
|
1680
|
+
}
|
|
1681
|
+
signalEmitter.next(tick);
|
|
1682
|
+
}
|
|
1683
|
+
return tick;
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Maps FrameInterval to minutes for timestamp calculation.
|
|
1690
|
+
* Used to generate timeframe arrays with proper spacing.
|
|
1691
|
+
*/
|
|
1692
|
+
const INTERVAL_MINUTES = {
|
|
1693
|
+
"1m": 1,
|
|
1694
|
+
"3m": 3,
|
|
1695
|
+
"5m": 5,
|
|
1696
|
+
"15m": 15,
|
|
1697
|
+
"30m": 30,
|
|
1698
|
+
"1h": 60,
|
|
1699
|
+
"2h": 120,
|
|
1700
|
+
"4h": 240,
|
|
1701
|
+
"6h": 360,
|
|
1702
|
+
"8h": 480,
|
|
1703
|
+
"12h": 720,
|
|
1704
|
+
"1d": 1440,
|
|
1705
|
+
"3d": 4320,
|
|
1706
|
+
};
|
|
1707
|
+
/**
|
|
1708
|
+
* Generates timeframe array from startDate to endDate with specified interval.
|
|
1709
|
+
* Uses prototype function pattern for memory efficiency.
|
|
1710
|
+
*
|
|
1711
|
+
* @param symbol - Trading pair symbol (unused, for API consistency)
|
|
1712
|
+
* @param self - ClientFrame instance reference
|
|
1713
|
+
* @returns Array of Date objects representing tick timestamps
|
|
1714
|
+
* @throws Error if interval is unknown
|
|
1715
|
+
*/
|
|
1716
|
+
const GET_TIMEFRAME_FN = async (symbol, self) => {
|
|
1717
|
+
self.params.logger.debug("ClientFrame getTimeframe", {
|
|
1718
|
+
symbol,
|
|
1719
|
+
});
|
|
1720
|
+
const { interval, startDate, endDate } = self.params;
|
|
1721
|
+
const intervalMinutes = INTERVAL_MINUTES[interval];
|
|
1722
|
+
if (!intervalMinutes) {
|
|
1723
|
+
throw new Error(`ClientFrame unknown interval: ${interval}`);
|
|
1724
|
+
}
|
|
1725
|
+
const timeframes = [];
|
|
1726
|
+
let currentDate = new Date(startDate);
|
|
1727
|
+
while (currentDate <= endDate) {
|
|
1728
|
+
timeframes.push(new Date(currentDate));
|
|
1729
|
+
currentDate = new Date(currentDate.getTime() + intervalMinutes * 60 * 1000);
|
|
1730
|
+
}
|
|
1731
|
+
if (self.params.callbacks?.onTimeframe) {
|
|
1732
|
+
self.params.callbacks.onTimeframe(timeframes, startDate, endDate, interval);
|
|
1733
|
+
}
|
|
1734
|
+
return timeframes;
|
|
1735
|
+
};
|
|
1736
|
+
/**
|
|
1737
|
+
* Client implementation for backtest timeframe generation.
|
|
1738
|
+
*
|
|
1739
|
+
* Features:
|
|
1740
|
+
* - Generates timestamp arrays for backtest iteration
|
|
1741
|
+
* - Singleshot caching prevents redundant generation
|
|
1742
|
+
* - Configurable interval spacing (1m to 3d)
|
|
1743
|
+
* - Callback support for validation and logging
|
|
1744
|
+
*
|
|
1745
|
+
* Used by BacktestLogicPrivateService to iterate through historical periods.
|
|
1746
|
+
*/
|
|
1747
|
+
class ClientFrame {
|
|
1748
|
+
constructor(params) {
|
|
1749
|
+
this.params = params;
|
|
1750
|
+
/**
|
|
1751
|
+
* Generates timeframe array for backtest period.
|
|
1752
|
+
* Results are cached via singleshot pattern.
|
|
1753
|
+
*
|
|
1754
|
+
* @param symbol - Trading pair symbol (unused, for API consistency)
|
|
1755
|
+
* @returns Promise resolving to array of Date objects
|
|
1756
|
+
* @throws Error if interval is invalid
|
|
1757
|
+
*/
|
|
1758
|
+
this.getTimeframe = singleshot(async (symbol) => await GET_TIMEFRAME_FN(symbol, this));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Connection service routing frame operations to correct ClientFrame instance.
|
|
1764
|
+
*
|
|
1765
|
+
* Routes all IFrame method calls to the appropriate frame implementation
|
|
1766
|
+
* based on methodContextService.context.frameName. Uses memoization to cache
|
|
1767
|
+
* ClientFrame instances for performance.
|
|
1768
|
+
*
|
|
1769
|
+
* Key features:
|
|
1770
|
+
* - Automatic frame routing via method context
|
|
1771
|
+
* - Memoized ClientFrame instances by frameName
|
|
1772
|
+
* - Implements IFrame interface
|
|
1773
|
+
* - Backtest timeframe management (startDate, endDate, interval)
|
|
1774
|
+
*
|
|
1775
|
+
* Note: frameName is empty string for live mode (no frame constraints).
|
|
1776
|
+
*
|
|
1777
|
+
* @example
|
|
1778
|
+
* ```typescript
|
|
1779
|
+
* // Used internally by framework
|
|
1780
|
+
* const timeframe = await frameConnectionService.getTimeframe("BTCUSDT");
|
|
1781
|
+
* // Automatically routes to correct frame based on methodContext
|
|
1782
|
+
* ```
|
|
1783
|
+
*/
|
|
1784
|
+
class FrameConnectionService {
|
|
1785
|
+
constructor() {
|
|
1786
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
1787
|
+
this.frameSchemaService = inject(TYPES.frameSchemaService);
|
|
1788
|
+
this.methodContextService = inject(TYPES.methodContextService);
|
|
1789
|
+
/**
|
|
1790
|
+
* Retrieves memoized ClientFrame instance for given frame name.
|
|
1791
|
+
*
|
|
1792
|
+
* Creates ClientFrame on first call, returns cached instance on subsequent calls.
|
|
1793
|
+
* Cache key is frameName string.
|
|
1794
|
+
*
|
|
1795
|
+
* @param frameName - Name of registered frame schema
|
|
1796
|
+
* @returns Configured ClientFrame instance
|
|
1797
|
+
*/
|
|
1798
|
+
this.getFrame = memoize(([frameName]) => `${frameName}`, (frameName) => {
|
|
1799
|
+
const { endDate, interval, startDate, callbacks } = this.frameSchemaService.get(frameName);
|
|
1800
|
+
return new ClientFrame({
|
|
1801
|
+
frameName,
|
|
1802
|
+
logger: this.loggerService,
|
|
1803
|
+
startDate,
|
|
1804
|
+
endDate,
|
|
1805
|
+
interval,
|
|
1806
|
+
callbacks,
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
/**
|
|
1810
|
+
* Retrieves backtest timeframe boundaries for symbol.
|
|
1811
|
+
*
|
|
1812
|
+
* Returns startDate and endDate from frame configuration.
|
|
1813
|
+
* Used to limit backtest execution to specific date range.
|
|
1814
|
+
*
|
|
1815
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
1816
|
+
* @returns Promise resolving to { startDate: Date, endDate: Date }
|
|
1817
|
+
*/
|
|
1818
|
+
this.getTimeframe = async (symbol) => {
|
|
1819
|
+
this.loggerService.log("frameConnectionService getTimeframe", {
|
|
1820
|
+
symbol,
|
|
1821
|
+
});
|
|
1822
|
+
return await this.getFrame(this.methodContextService.context.frameName).getTimeframe(symbol);
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Global service for exchange operations with execution context injection.
|
|
1829
|
+
*
|
|
1830
|
+
* Wraps ExchangeConnectionService with ExecutionContextService to inject
|
|
1831
|
+
* symbol, when, and backtest parameters into the execution context.
|
|
1832
|
+
*
|
|
1833
|
+
* Used internally by BacktestLogicPrivateService and LiveLogicPrivateService.
|
|
1834
|
+
*/
|
|
1835
|
+
class ExchangeGlobalService {
|
|
1836
|
+
constructor() {
|
|
1837
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
1838
|
+
this.exchangeConnectionService = inject(TYPES.exchangeConnectionService);
|
|
1839
|
+
/**
|
|
1840
|
+
* Fetches historical candles with execution context.
|
|
1841
|
+
*
|
|
1842
|
+
* @param symbol - Trading pair symbol
|
|
1843
|
+
* @param interval - Candle interval (e.g., "1m", "1h")
|
|
1844
|
+
* @param limit - Maximum number of candles to fetch
|
|
1845
|
+
* @param when - Timestamp for context (used in backtest mode)
|
|
1846
|
+
* @param backtest - Whether running in backtest mode
|
|
1847
|
+
* @returns Promise resolving to array of candles
|
|
1848
|
+
*/
|
|
1849
|
+
this.getCandles = async (symbol, interval, limit, when, backtest) => {
|
|
1850
|
+
this.loggerService.log("exchangeGlobalService getCandles", {
|
|
1851
|
+
symbol,
|
|
1852
|
+
interval,
|
|
1853
|
+
limit,
|
|
1854
|
+
when,
|
|
1855
|
+
backtest,
|
|
1856
|
+
});
|
|
1857
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
1858
|
+
return await this.exchangeConnectionService.getCandles(symbol, interval, limit);
|
|
1859
|
+
}, {
|
|
1860
|
+
symbol,
|
|
1861
|
+
when,
|
|
1862
|
+
backtest,
|
|
1863
|
+
});
|
|
1864
|
+
};
|
|
1865
|
+
/**
|
|
1866
|
+
* Fetches future candles (backtest mode only) with execution context.
|
|
1867
|
+
*
|
|
1868
|
+
* @param symbol - Trading pair symbol
|
|
1869
|
+
* @param interval - Candle interval
|
|
1870
|
+
* @param limit - Maximum number of candles to fetch
|
|
1871
|
+
* @param when - Timestamp for context
|
|
1872
|
+
* @param backtest - Whether running in backtest mode (must be true)
|
|
1873
|
+
* @returns Promise resolving to array of future candles
|
|
1874
|
+
*/
|
|
1875
|
+
this.getNextCandles = async (symbol, interval, limit, when, backtest) => {
|
|
1876
|
+
this.loggerService.log("exchangeGlobalService getNextCandles", {
|
|
1877
|
+
symbol,
|
|
1878
|
+
interval,
|
|
1879
|
+
limit,
|
|
1880
|
+
when,
|
|
1881
|
+
backtest,
|
|
1882
|
+
});
|
|
1883
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
1884
|
+
return await this.exchangeConnectionService.getNextCandles(symbol, interval, limit);
|
|
1885
|
+
}, {
|
|
1886
|
+
symbol,
|
|
1887
|
+
when,
|
|
1888
|
+
backtest,
|
|
1889
|
+
});
|
|
1890
|
+
};
|
|
1891
|
+
/**
|
|
1892
|
+
* Calculates VWAP with execution context.
|
|
1893
|
+
*
|
|
1894
|
+
* @param symbol - Trading pair symbol
|
|
1895
|
+
* @param when - Timestamp for context
|
|
1896
|
+
* @param backtest - Whether running in backtest mode
|
|
1897
|
+
* @returns Promise resolving to VWAP price
|
|
1898
|
+
*/
|
|
1899
|
+
this.getAveragePrice = async (symbol, when, backtest) => {
|
|
1900
|
+
this.loggerService.log("exchangeGlobalService getAveragePrice", {
|
|
1901
|
+
symbol,
|
|
1902
|
+
when,
|
|
1903
|
+
backtest,
|
|
1904
|
+
});
|
|
1905
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
1906
|
+
return await this.exchangeConnectionService.getAveragePrice(symbol);
|
|
1907
|
+
}, {
|
|
1908
|
+
symbol,
|
|
1909
|
+
when,
|
|
1910
|
+
backtest,
|
|
1911
|
+
});
|
|
1912
|
+
};
|
|
1913
|
+
/**
|
|
1914
|
+
* Formats price with execution context.
|
|
1915
|
+
*
|
|
1916
|
+
* @param symbol - Trading pair symbol
|
|
1917
|
+
* @param price - Price to format
|
|
1918
|
+
* @param when - Timestamp for context
|
|
1919
|
+
* @param backtest - Whether running in backtest mode
|
|
1920
|
+
* @returns Promise resolving to formatted price string
|
|
1921
|
+
*/
|
|
1922
|
+
this.formatPrice = async (symbol, price, when, backtest) => {
|
|
1923
|
+
this.loggerService.log("exchangeGlobalService formatPrice", {
|
|
1924
|
+
symbol,
|
|
1925
|
+
price,
|
|
1926
|
+
when,
|
|
1927
|
+
backtest,
|
|
1928
|
+
});
|
|
1929
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
1930
|
+
return await this.exchangeConnectionService.formatPrice(symbol, price);
|
|
1931
|
+
}, {
|
|
1932
|
+
symbol,
|
|
1933
|
+
when,
|
|
1934
|
+
backtest,
|
|
1935
|
+
});
|
|
1936
|
+
};
|
|
1937
|
+
/**
|
|
1938
|
+
* Formats quantity with execution context.
|
|
1939
|
+
*
|
|
1940
|
+
* @param symbol - Trading pair symbol
|
|
1941
|
+
* @param quantity - Quantity to format
|
|
1942
|
+
* @param when - Timestamp for context
|
|
1943
|
+
* @param backtest - Whether running in backtest mode
|
|
1944
|
+
* @returns Promise resolving to formatted quantity string
|
|
1945
|
+
*/
|
|
1946
|
+
this.formatQuantity = async (symbol, quantity, when, backtest) => {
|
|
1947
|
+
this.loggerService.log("exchangeGlobalService formatQuantity", {
|
|
1948
|
+
symbol,
|
|
1949
|
+
quantity,
|
|
1950
|
+
when,
|
|
1951
|
+
backtest,
|
|
1952
|
+
});
|
|
1953
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
1954
|
+
return await this.exchangeConnectionService.formatQuantity(symbol, quantity);
|
|
1955
|
+
}, {
|
|
1956
|
+
symbol,
|
|
1957
|
+
when,
|
|
1958
|
+
backtest,
|
|
1959
|
+
});
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Global service for strategy operations with execution context injection.
|
|
1966
|
+
*
|
|
1967
|
+
* Wraps StrategyConnectionService with ExecutionContextService to inject
|
|
1968
|
+
* symbol, when, and backtest parameters into the execution context.
|
|
1969
|
+
*
|
|
1970
|
+
* Used internally by BacktestLogicPrivateService and LiveLogicPrivateService.
|
|
1971
|
+
*/
|
|
1972
|
+
class StrategyGlobalService {
|
|
1973
|
+
constructor() {
|
|
1974
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
1975
|
+
this.strategyConnectionService = inject(TYPES.strategyConnectionService);
|
|
1976
|
+
/**
|
|
1977
|
+
* Checks signal status at a specific timestamp.
|
|
1978
|
+
*
|
|
1979
|
+
* Wraps strategy tick() with execution context containing symbol, timestamp,
|
|
1980
|
+
* and backtest mode flag.
|
|
1981
|
+
*
|
|
1982
|
+
* @param symbol - Trading pair symbol
|
|
1983
|
+
* @param when - Timestamp for tick evaluation
|
|
1984
|
+
* @param backtest - Whether running in backtest mode
|
|
1985
|
+
* @returns Discriminated union of tick result (idle, opened, active, closed)
|
|
1986
|
+
*/
|
|
1987
|
+
this.tick = async (symbol, when, backtest) => {
|
|
1988
|
+
this.loggerService.log("strategyGlobalService tick", {
|
|
1989
|
+
symbol,
|
|
1990
|
+
when,
|
|
1991
|
+
backtest,
|
|
1992
|
+
});
|
|
1993
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
1994
|
+
return await this.strategyConnectionService.tick();
|
|
1995
|
+
}, {
|
|
1996
|
+
symbol,
|
|
1997
|
+
when,
|
|
1998
|
+
backtest,
|
|
1999
|
+
});
|
|
2000
|
+
};
|
|
2001
|
+
/**
|
|
2002
|
+
* Runs fast backtest against candle array.
|
|
2003
|
+
*
|
|
2004
|
+
* Wraps strategy backtest() with execution context containing symbol,
|
|
2005
|
+
* timestamp, and backtest mode flag.
|
|
2006
|
+
*
|
|
2007
|
+
* @param symbol - Trading pair symbol
|
|
2008
|
+
* @param candles - Array of historical candles to test against
|
|
2009
|
+
* @param when - Starting timestamp for backtest
|
|
2010
|
+
* @param backtest - Whether running in backtest mode (typically true)
|
|
2011
|
+
* @returns Closed signal result with PNL
|
|
2012
|
+
*/
|
|
2013
|
+
this.backtest = async (symbol, candles, when, backtest) => {
|
|
2014
|
+
this.loggerService.log("strategyGlobalService backtest", {
|
|
2015
|
+
symbol,
|
|
2016
|
+
candleCount: candles.length,
|
|
2017
|
+
when,
|
|
2018
|
+
backtest,
|
|
2019
|
+
});
|
|
2020
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
2021
|
+
return await this.strategyConnectionService.backtest(candles);
|
|
2022
|
+
}, {
|
|
2023
|
+
symbol,
|
|
2024
|
+
when,
|
|
2025
|
+
backtest,
|
|
2026
|
+
});
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* Global service for frame operations.
|
|
2033
|
+
*
|
|
2034
|
+
* Wraps FrameConnectionService for timeframe generation.
|
|
2035
|
+
* Used internally by BacktestLogicPrivateService.
|
|
2036
|
+
*/
|
|
2037
|
+
class FrameGlobalService {
|
|
2038
|
+
constructor() {
|
|
2039
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2040
|
+
this.frameConnectionService = inject(TYPES.frameConnectionService);
|
|
2041
|
+
/**
|
|
2042
|
+
* Generates timeframe array for backtest iteration.
|
|
2043
|
+
*
|
|
2044
|
+
* @param symbol - Trading pair symbol
|
|
2045
|
+
* @returns Promise resolving to array of Date objects
|
|
2046
|
+
*/
|
|
2047
|
+
this.getTimeframe = async (symbol) => {
|
|
2048
|
+
this.loggerService.log("frameGlobalService getTimeframe", {
|
|
2049
|
+
symbol,
|
|
2050
|
+
});
|
|
2051
|
+
return await this.frameConnectionService.getTimeframe(symbol);
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* Service for managing exchange schema registry.
|
|
2058
|
+
*
|
|
2059
|
+
* Uses ToolRegistry from functools-kit for type-safe schema storage.
|
|
2060
|
+
* Exchanges are registered via addExchange() and retrieved by name.
|
|
2061
|
+
*/
|
|
2062
|
+
class ExchangeSchemaService {
|
|
2063
|
+
constructor() {
|
|
2064
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2065
|
+
this._registry = new ToolRegistry("exchangeSchema");
|
|
2066
|
+
/**
|
|
2067
|
+
* Registers a new exchange schema.
|
|
2068
|
+
*
|
|
2069
|
+
* @param key - Unique exchange name
|
|
2070
|
+
* @param value - Exchange schema configuration
|
|
2071
|
+
* @throws Error if exchange name already exists
|
|
2072
|
+
*/
|
|
2073
|
+
this.register = (key, value) => {
|
|
2074
|
+
this.loggerService.log(`exchangeSchemaService register`, { key });
|
|
2075
|
+
this.validateShallow(value);
|
|
2076
|
+
this._registry = this._registry.register(key, value);
|
|
2077
|
+
};
|
|
2078
|
+
/**
|
|
2079
|
+
* Validates exchange schema structure for required properties.
|
|
2080
|
+
*
|
|
2081
|
+
* Performs shallow validation to ensure all required properties exist
|
|
2082
|
+
* and have correct types before registration in the registry.
|
|
2083
|
+
*
|
|
2084
|
+
* @param exchangeSchema - Exchange schema to validate
|
|
2085
|
+
* @throws Error if exchangeName is missing or not a string
|
|
2086
|
+
* @throws Error if getCandles is missing or not a function
|
|
2087
|
+
* @throws Error if formatPrice is missing or not a function
|
|
2088
|
+
* @throws Error if formatQuantity is missing or not a function
|
|
2089
|
+
*/
|
|
2090
|
+
this.validateShallow = (exchangeSchema) => {
|
|
2091
|
+
this.loggerService.log(`exchangeSchemaService validateShallow`, {
|
|
2092
|
+
exchangeSchema,
|
|
2093
|
+
});
|
|
2094
|
+
if (typeof exchangeSchema.exchangeName !== "string") {
|
|
2095
|
+
throw new Error(`exchange schema validation failed: missing exchangeName`);
|
|
2096
|
+
}
|
|
2097
|
+
if (typeof exchangeSchema.getCandles !== "function") {
|
|
2098
|
+
throw new Error(`exchange schema validation failed: missing getCandles for exchangeName=${exchangeSchema.exchangeName}`);
|
|
2099
|
+
}
|
|
2100
|
+
if (typeof exchangeSchema.formatPrice !== "function") {
|
|
2101
|
+
throw new Error(`exchange schema validation failed: missing formatPrice for exchangeName=${exchangeSchema.exchangeName}`);
|
|
2102
|
+
}
|
|
2103
|
+
if (typeof exchangeSchema.formatQuantity !== "function") {
|
|
2104
|
+
throw new Error(`exchange schema validation failed: missing formatQuantity for exchangeName=${exchangeSchema.exchangeName}`);
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
/**
|
|
2108
|
+
* Overrides an existing exchange schema with partial updates.
|
|
2109
|
+
*
|
|
2110
|
+
* @param key - Exchange name to override
|
|
2111
|
+
* @param value - Partial schema updates
|
|
2112
|
+
* @returns Updated exchange schema
|
|
2113
|
+
* @throws Error if exchange name doesn't exist
|
|
2114
|
+
*/
|
|
2115
|
+
this.override = (key, value) => {
|
|
2116
|
+
this.loggerService.log(`exchangeSchemaService override`, { key });
|
|
2117
|
+
this._registry = this._registry.override(key, value);
|
|
2118
|
+
return this._registry.get(key);
|
|
2119
|
+
};
|
|
2120
|
+
/**
|
|
2121
|
+
* Retrieves an exchange schema by name.
|
|
2122
|
+
*
|
|
2123
|
+
* @param key - Exchange name
|
|
2124
|
+
* @returns Exchange schema configuration
|
|
2125
|
+
* @throws Error if exchange name doesn't exist
|
|
2126
|
+
*/
|
|
2127
|
+
this.get = (key) => {
|
|
2128
|
+
this.loggerService.log(`exchangeSchemaService get`, { key });
|
|
2129
|
+
return this._registry.get(key);
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* Service for managing strategy schema registry.
|
|
2136
|
+
*
|
|
2137
|
+
* Uses ToolRegistry from functools-kit for type-safe schema storage.
|
|
2138
|
+
* Strategies are registered via addStrategy() and retrieved by name.
|
|
2139
|
+
*/
|
|
2140
|
+
class StrategySchemaService {
|
|
2141
|
+
constructor() {
|
|
2142
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2143
|
+
this._registry = new ToolRegistry("strategySchema");
|
|
2144
|
+
/**
|
|
2145
|
+
* Registers a new strategy schema.
|
|
2146
|
+
*
|
|
2147
|
+
* @param key - Unique strategy name
|
|
2148
|
+
* @param value - Strategy schema configuration
|
|
2149
|
+
* @throws Error if strategy name already exists
|
|
2150
|
+
*/
|
|
2151
|
+
this.register = (key, value) => {
|
|
2152
|
+
this.loggerService.log(`strategySchemaService register`, { key });
|
|
2153
|
+
this.validateShallow(value);
|
|
2154
|
+
this._registry = this._registry.register(key, value);
|
|
2155
|
+
};
|
|
2156
|
+
/**
|
|
2157
|
+
* Validates strategy schema structure for required properties.
|
|
2158
|
+
*
|
|
2159
|
+
* Performs shallow validation to ensure all required properties exist
|
|
2160
|
+
* and have correct types before registration in the registry.
|
|
2161
|
+
*
|
|
2162
|
+
* @param strategySchema - Strategy schema to validate
|
|
2163
|
+
* @throws Error if strategyName is missing or not a string
|
|
2164
|
+
* @throws Error if interval is missing or not a valid SignalInterval
|
|
2165
|
+
* @throws Error if getSignal is missing or not a function
|
|
2166
|
+
*/
|
|
2167
|
+
this.validateShallow = (strategySchema) => {
|
|
2168
|
+
this.loggerService.log(`strategySchemaService validateShallow`, {
|
|
2169
|
+
strategySchema,
|
|
2170
|
+
});
|
|
2171
|
+
if (typeof strategySchema.strategyName !== "string") {
|
|
2172
|
+
throw new Error(`strategy schema validation failed: missing strategyName`);
|
|
2173
|
+
}
|
|
2174
|
+
if (typeof strategySchema.interval !== "string") {
|
|
2175
|
+
throw new Error(`strategy schema validation failed: missing interval for strategyName=${strategySchema.strategyName}`);
|
|
2176
|
+
}
|
|
2177
|
+
if (typeof strategySchema.getSignal !== "function") {
|
|
2178
|
+
throw new Error(`strategy schema validation failed: missing getSignal for strategyName=${strategySchema.strategyName}`);
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
/**
|
|
2182
|
+
* Overrides an existing strategy schema with partial updates.
|
|
2183
|
+
*
|
|
2184
|
+
* @param key - Strategy name to override
|
|
2185
|
+
* @param value - Partial schema updates
|
|
2186
|
+
* @returns Updated strategy schema
|
|
2187
|
+
* @throws Error if strategy name doesn't exist
|
|
2188
|
+
*/
|
|
2189
|
+
this.override = (key, value) => {
|
|
2190
|
+
this.loggerService.log(`strategySchemaService override`, { key });
|
|
2191
|
+
this._registry = this._registry.override(key, value);
|
|
2192
|
+
return this._registry.get(key);
|
|
2193
|
+
};
|
|
2194
|
+
/**
|
|
2195
|
+
* Retrieves a strategy schema by name.
|
|
2196
|
+
*
|
|
2197
|
+
* @param key - Strategy name
|
|
2198
|
+
* @returns Strategy schema configuration
|
|
2199
|
+
* @throws Error if strategy name doesn't exist
|
|
2200
|
+
*/
|
|
2201
|
+
this.get = (key) => {
|
|
2202
|
+
this.loggerService.log(`strategySchemaService get`, { key });
|
|
2203
|
+
return this._registry.get(key);
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* Service for managing frame schema registry.
|
|
2210
|
+
*
|
|
2211
|
+
* Uses ToolRegistry from functools-kit for type-safe schema storage.
|
|
2212
|
+
* Frames are registered via addFrame() and retrieved by name.
|
|
2213
|
+
*/
|
|
2214
|
+
class FrameSchemaService {
|
|
2215
|
+
constructor() {
|
|
2216
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2217
|
+
this._registry = new ToolRegistry("frameSchema");
|
|
2218
|
+
/**
|
|
2219
|
+
* Validates frame schema structure for required properties.
|
|
2220
|
+
*
|
|
2221
|
+
* Performs shallow validation to ensure all required properties exist
|
|
2222
|
+
* and have correct types before registration in the registry.
|
|
2223
|
+
*
|
|
2224
|
+
* @param frameSchema - Frame schema to validate
|
|
2225
|
+
* @throws Error if frameName is missing or not a string
|
|
2226
|
+
* @throws Error if interval is missing or not a valid FrameInterval
|
|
2227
|
+
* @throws Error if startDate is missing or not a Date
|
|
2228
|
+
* @throws Error if endDate is missing or not a Date
|
|
2229
|
+
*/
|
|
2230
|
+
this.validateShallow = (frameSchema) => {
|
|
2231
|
+
this.loggerService.log(`frameSchemaService validateShallow`, {
|
|
2232
|
+
frameSchema,
|
|
2233
|
+
});
|
|
2234
|
+
if (typeof frameSchema.frameName !== "string") {
|
|
2235
|
+
throw new Error(`frame schema validation failed: missing frameName`);
|
|
2236
|
+
}
|
|
2237
|
+
if (typeof frameSchema.interval !== "string") {
|
|
2238
|
+
throw new Error(`frame schema validation failed: missing interval for frameName=${frameSchema.frameName}`);
|
|
2239
|
+
}
|
|
2240
|
+
if (!(frameSchema.startDate instanceof Date)) {
|
|
2241
|
+
throw new Error(`frame schema validation failed: missing startDate for frameName=${frameSchema.frameName}`);
|
|
2242
|
+
}
|
|
2243
|
+
if (!(frameSchema.endDate instanceof Date)) {
|
|
2244
|
+
throw new Error(`frame schema validation failed: missing endDate for frameName=${frameSchema.frameName}`);
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Registers a new frame schema.
|
|
2250
|
+
*
|
|
2251
|
+
* @param key - Unique frame name
|
|
2252
|
+
* @param value - Frame schema configuration
|
|
2253
|
+
* @throws Error if frame name already exists
|
|
2254
|
+
*/
|
|
2255
|
+
register(key, value) {
|
|
2256
|
+
this.loggerService.log(`frameSchemaService register`, { key });
|
|
2257
|
+
this.validateShallow(value);
|
|
2258
|
+
this._registry.register(key, value);
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Overrides an existing frame schema with partial updates.
|
|
2262
|
+
*
|
|
2263
|
+
* @param key - Frame name to override
|
|
2264
|
+
* @param value - Partial schema updates
|
|
2265
|
+
* @throws Error if frame name doesn't exist
|
|
2266
|
+
*/
|
|
2267
|
+
override(key, value) {
|
|
2268
|
+
this.loggerService.log(`frameSchemaService override`, { key });
|
|
2269
|
+
this._registry.override(key, value);
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Retrieves a frame schema by name.
|
|
2273
|
+
*
|
|
2274
|
+
* @param key - Frame name
|
|
2275
|
+
* @returns Frame schema configuration
|
|
2276
|
+
* @throws Error if frame name doesn't exist
|
|
2277
|
+
*/
|
|
2278
|
+
get(key) {
|
|
2279
|
+
this.loggerService.log(`frameSchemaService get`, { key });
|
|
2280
|
+
return this._registry.get(key);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Private service for backtest orchestration using async generators.
|
|
2286
|
+
*
|
|
2287
|
+
* Flow:
|
|
2288
|
+
* 1. Get timeframes from frame service
|
|
2289
|
+
* 2. Iterate through timeframes calling tick()
|
|
2290
|
+
* 3. When signal opens: fetch candles and call backtest()
|
|
2291
|
+
* 4. Skip timeframes until signal closes
|
|
2292
|
+
* 5. Yield closed result and continue
|
|
2293
|
+
*
|
|
2294
|
+
* Memory efficient: streams results without array accumulation.
|
|
2295
|
+
* Supports early termination via break in consumer.
|
|
2296
|
+
*/
|
|
2297
|
+
class BacktestLogicPrivateService {
|
|
2298
|
+
constructor() {
|
|
2299
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2300
|
+
this.strategyGlobalService = inject(TYPES.strategyGlobalService);
|
|
2301
|
+
this.exchangeGlobalService = inject(TYPES.exchangeGlobalService);
|
|
2302
|
+
this.frameGlobalService = inject(TYPES.frameGlobalService);
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Runs backtest for a symbol, streaming closed signals as async generator.
|
|
2306
|
+
*
|
|
2307
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2308
|
+
* @yields Closed signal results with PNL
|
|
2309
|
+
*
|
|
2310
|
+
* @example
|
|
2311
|
+
* ```typescript
|
|
2312
|
+
* for await (const result of backtestLogic.run("BTCUSDT")) {
|
|
2313
|
+
* console.log(result.closeReason, result.pnl.pnlPercentage);
|
|
2314
|
+
* if (result.pnl.pnlPercentage < -10) break; // Early termination
|
|
2315
|
+
* }
|
|
2316
|
+
* ```
|
|
2317
|
+
*/
|
|
2318
|
+
async *run(symbol) {
|
|
2319
|
+
this.loggerService.log("backtestLogicPrivateService run", {
|
|
2320
|
+
symbol,
|
|
2321
|
+
});
|
|
2322
|
+
const timeframes = await this.frameGlobalService.getTimeframe(symbol);
|
|
2323
|
+
let i = 0;
|
|
2324
|
+
while (i < timeframes.length) {
|
|
2325
|
+
const when = timeframes[i];
|
|
2326
|
+
const result = await this.strategyGlobalService.tick(symbol, when, true);
|
|
2327
|
+
// Если сигнал открыт, вызываем backtest
|
|
2328
|
+
if (result.action === "opened") {
|
|
2329
|
+
const signal = result.signal;
|
|
2330
|
+
this.loggerService.info("backtestLogicPrivateService signal opened", {
|
|
2331
|
+
symbol,
|
|
2332
|
+
signalId: signal.id,
|
|
2333
|
+
minuteEstimatedTime: signal.minuteEstimatedTime,
|
|
2334
|
+
});
|
|
2335
|
+
// Получаем свечи для бектеста
|
|
2336
|
+
const candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", signal.minuteEstimatedTime, when, true);
|
|
2337
|
+
if (!candles.length) {
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
this.loggerService.info("backtestLogicPrivateService candles fetched", {
|
|
2341
|
+
symbol,
|
|
2342
|
+
signalId: signal.id,
|
|
2343
|
+
candlesCount: candles.length,
|
|
2344
|
+
});
|
|
2345
|
+
// Вызываем backtest - всегда возвращает closed
|
|
2346
|
+
const backtestResult = await this.strategyGlobalService.backtest(symbol, candles, when, true);
|
|
2347
|
+
this.loggerService.info("backtestLogicPrivateService signal closed", {
|
|
2348
|
+
symbol,
|
|
2349
|
+
signalId: backtestResult.signal.id,
|
|
2350
|
+
closeTimestamp: backtestResult.closeTimestamp,
|
|
2351
|
+
closeReason: backtestResult.closeReason,
|
|
2352
|
+
});
|
|
2353
|
+
// Пропускаем timeframes до closeTimestamp
|
|
2354
|
+
while (i < timeframes.length &&
|
|
2355
|
+
timeframes[i].getTime() < backtestResult.closeTimestamp) {
|
|
2356
|
+
i++;
|
|
2357
|
+
}
|
|
2358
|
+
yield backtestResult;
|
|
2359
|
+
}
|
|
2360
|
+
i++;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const TICK_TTL = 1 * 60 * 1000 + 1;
|
|
2366
|
+
/**
|
|
2367
|
+
* Private service for live trading orchestration using async generators.
|
|
2368
|
+
*
|
|
2369
|
+
* Flow:
|
|
2370
|
+
* 1. Infinite while(true) loop for continuous monitoring
|
|
2371
|
+
* 2. Create real-time date with new Date()
|
|
2372
|
+
* 3. Call tick() to check signal status
|
|
2373
|
+
* 4. Yield opened/closed results (skip idle/active)
|
|
2374
|
+
* 5. Sleep for TICK_TTL between iterations
|
|
2375
|
+
*
|
|
2376
|
+
* Features:
|
|
2377
|
+
* - Crash recovery via ClientStrategy.waitForInit()
|
|
2378
|
+
* - Real-time progression with new Date()
|
|
2379
|
+
* - Memory efficient streaming
|
|
2380
|
+
* - Never completes (infinite generator)
|
|
2381
|
+
*/
|
|
2382
|
+
class LiveLogicPrivateService {
|
|
2383
|
+
constructor() {
|
|
2384
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2385
|
+
this.strategyGlobalService = inject(TYPES.strategyGlobalService);
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Runs live trading for a symbol, streaming results as async generator.
|
|
2389
|
+
*
|
|
2390
|
+
* Infinite generator that yields opened and closed signals.
|
|
2391
|
+
* Process can crash and restart - state will be recovered from disk.
|
|
2392
|
+
*
|
|
2393
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2394
|
+
* @yields Opened and closed signal results
|
|
2395
|
+
*
|
|
2396
|
+
* @example
|
|
2397
|
+
* ```typescript
|
|
2398
|
+
* for await (const result of liveLogic.run("BTCUSDT")) {
|
|
2399
|
+
* if (result.action === "opened") {
|
|
2400
|
+
* console.log("New signal:", result.signal.id);
|
|
2401
|
+
* }
|
|
2402
|
+
* if (result.action === "closed") {
|
|
2403
|
+
* console.log("PNL:", result.pnl.pnlPercentage);
|
|
2404
|
+
* }
|
|
2405
|
+
* // Infinite loop - will never complete
|
|
2406
|
+
* }
|
|
2407
|
+
* ```
|
|
2408
|
+
*/
|
|
2409
|
+
async *run(symbol) {
|
|
2410
|
+
this.loggerService.log("liveLogicPrivateService run", {
|
|
2411
|
+
symbol,
|
|
2412
|
+
});
|
|
2413
|
+
while (true) {
|
|
2414
|
+
const when = new Date();
|
|
2415
|
+
const result = await this.strategyGlobalService.tick(symbol, when, false);
|
|
2416
|
+
this.loggerService.info("liveLogicPrivateService tick result", {
|
|
2417
|
+
symbol,
|
|
2418
|
+
action: result.action,
|
|
2419
|
+
});
|
|
2420
|
+
if (result.action === "active") {
|
|
2421
|
+
await sleep(TICK_TTL);
|
|
2422
|
+
continue;
|
|
2423
|
+
}
|
|
2424
|
+
if (result.action === "idle") {
|
|
2425
|
+
await sleep(TICK_TTL);
|
|
2426
|
+
continue;
|
|
2427
|
+
}
|
|
2428
|
+
yield result;
|
|
2429
|
+
await sleep(TICK_TTL);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/**
|
|
2435
|
+
* Public service for backtest orchestration with context management.
|
|
2436
|
+
*
|
|
2437
|
+
* Wraps BacktestLogicPrivateService with MethodContextService to provide
|
|
2438
|
+
* implicit context propagation for strategyName, exchangeName, and frameName.
|
|
2439
|
+
*
|
|
2440
|
+
* This allows getCandles(), getSignal(), and other functions to work without
|
|
2441
|
+
* explicit context parameters.
|
|
2442
|
+
*
|
|
2443
|
+
* @example
|
|
2444
|
+
* ```typescript
|
|
2445
|
+
* const backtestLogicPublicService = inject(TYPES.backtestLogicPublicService);
|
|
2446
|
+
*
|
|
2447
|
+
* for await (const result of backtestLogicPublicService.run("BTCUSDT", {
|
|
2448
|
+
* strategyName: "my-strategy",
|
|
2449
|
+
* exchangeName: "my-exchange",
|
|
2450
|
+
* frameName: "1d-backtest",
|
|
2451
|
+
* })) {
|
|
2452
|
+
* if (result.action === "closed") {
|
|
2453
|
+
* console.log("PNL:", result.pnl.profit);
|
|
2454
|
+
* }
|
|
2455
|
+
* }
|
|
2456
|
+
* ```
|
|
2457
|
+
*/
|
|
2458
|
+
class BacktestLogicPublicService {
|
|
2459
|
+
constructor() {
|
|
2460
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2461
|
+
this.backtestLogicPrivateService = inject(TYPES.backtestLogicPrivateService);
|
|
2462
|
+
/**
|
|
2463
|
+
* Runs backtest for a symbol with context propagation.
|
|
2464
|
+
*
|
|
2465
|
+
* Streams closed signals as async generator. Context is automatically
|
|
2466
|
+
* injected into all framework functions called during iteration.
|
|
2467
|
+
*
|
|
2468
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2469
|
+
* @param context - Execution context with strategy, exchange, and frame names
|
|
2470
|
+
* @returns Async generator yielding closed signals with PNL
|
|
2471
|
+
*/
|
|
2472
|
+
this.run = (symbol, context) => {
|
|
2473
|
+
this.loggerService.log("backtestLogicPublicService run", {
|
|
2474
|
+
symbol,
|
|
2475
|
+
context,
|
|
2476
|
+
});
|
|
2477
|
+
return MethodContextService.runAsyncIterator(this.backtestLogicPrivateService.run(symbol), {
|
|
2478
|
+
exchangeName: context.exchangeName,
|
|
2479
|
+
strategyName: context.strategyName,
|
|
2480
|
+
frameName: context.frameName,
|
|
2481
|
+
});
|
|
2482
|
+
};
|
|
345
2483
|
}
|
|
346
|
-
}
|
|
2484
|
+
}
|
|
347
2485
|
|
|
348
|
-
|
|
2486
|
+
/**
|
|
2487
|
+
* Public service for live trading orchestration with context management.
|
|
2488
|
+
*
|
|
2489
|
+
* Wraps LiveLogicPrivateService with MethodContextService to provide
|
|
2490
|
+
* implicit context propagation for strategyName and exchangeName.
|
|
2491
|
+
*
|
|
2492
|
+
* This allows getCandles(), getSignal(), and other functions to work without
|
|
2493
|
+
* explicit context parameters.
|
|
2494
|
+
*
|
|
2495
|
+
* Features:
|
|
2496
|
+
* - Infinite async generator (never completes)
|
|
2497
|
+
* - Crash recovery via persisted state
|
|
2498
|
+
* - Real-time progression with Date.now()
|
|
2499
|
+
*
|
|
2500
|
+
* @example
|
|
2501
|
+
* ```typescript
|
|
2502
|
+
* const liveLogicPublicService = inject(TYPES.liveLogicPublicService);
|
|
2503
|
+
*
|
|
2504
|
+
* // Infinite loop - use Ctrl+C to stop
|
|
2505
|
+
* for await (const result of liveLogicPublicService.run("BTCUSDT", {
|
|
2506
|
+
* strategyName: "my-strategy",
|
|
2507
|
+
* exchangeName: "my-exchange",
|
|
2508
|
+
* })) {
|
|
2509
|
+
* if (result.action === "opened") {
|
|
2510
|
+
* console.log("Signal opened:", result.signal);
|
|
2511
|
+
* } else if (result.action === "closed") {
|
|
2512
|
+
* console.log("PNL:", result.pnl.profit);
|
|
2513
|
+
* }
|
|
2514
|
+
* }
|
|
2515
|
+
* ```
|
|
2516
|
+
*/
|
|
2517
|
+
class LiveLogicPublicService {
|
|
349
2518
|
constructor() {
|
|
350
2519
|
this.loggerService = inject(TYPES.loggerService);
|
|
351
|
-
this.
|
|
352
|
-
|
|
353
|
-
|
|
2520
|
+
this.liveLogicPrivateService = inject(TYPES.liveLogicPrivateService);
|
|
2521
|
+
/**
|
|
2522
|
+
* Runs live trading for a symbol with context propagation.
|
|
2523
|
+
*
|
|
2524
|
+
* Streams opened and closed signals as infinite async generator.
|
|
2525
|
+
* Context is automatically injected into all framework functions.
|
|
2526
|
+
* Process can crash and restart - state will be recovered from disk.
|
|
2527
|
+
*
|
|
2528
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2529
|
+
* @param context - Execution context with strategy and exchange names
|
|
2530
|
+
* @returns Infinite async generator yielding opened and closed signals
|
|
2531
|
+
*/
|
|
2532
|
+
this.run = (symbol, context) => {
|
|
2533
|
+
this.loggerService.log("liveLogicPublicService run", {
|
|
354
2534
|
symbol,
|
|
355
|
-
|
|
356
|
-
limit,
|
|
357
|
-
when,
|
|
358
|
-
backtest,
|
|
2535
|
+
context,
|
|
359
2536
|
});
|
|
360
|
-
return
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
backtest,
|
|
2537
|
+
return MethodContextService.runAsyncIterator(this.liveLogicPrivateService.run(symbol), {
|
|
2538
|
+
exchangeName: context.exchangeName,
|
|
2539
|
+
strategyName: context.strategyName,
|
|
2540
|
+
frameName: "",
|
|
365
2541
|
});
|
|
366
2542
|
};
|
|
367
|
-
|
|
368
|
-
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
const METHOD_NAME_RUN$1 = "liveGlobalService run";
|
|
2547
|
+
/**
|
|
2548
|
+
* Global service providing access to live trading functionality.
|
|
2549
|
+
*
|
|
2550
|
+
* Simple wrapper around LiveLogicPublicService for dependency injection.
|
|
2551
|
+
* Used by public API exports.
|
|
2552
|
+
*/
|
|
2553
|
+
class LiveGlobalService {
|
|
2554
|
+
constructor() {
|
|
2555
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2556
|
+
this.liveLogicPublicService = inject(TYPES.liveLogicPublicService);
|
|
2557
|
+
this.strategyValidationService = inject(TYPES.strategyValidationService);
|
|
2558
|
+
this.exchangeValidationService = inject(TYPES.exchangeValidationService);
|
|
2559
|
+
/**
|
|
2560
|
+
* Runs live trading for a symbol with context propagation.
|
|
2561
|
+
*
|
|
2562
|
+
* Infinite async generator with crash recovery support.
|
|
2563
|
+
*
|
|
2564
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2565
|
+
* @param context - Execution context with strategy and exchange names
|
|
2566
|
+
* @returns Infinite async generator yielding opened and closed signals
|
|
2567
|
+
*/
|
|
2568
|
+
this.run = (symbol, context) => {
|
|
2569
|
+
this.loggerService.log(METHOD_NAME_RUN$1, {
|
|
369
2570
|
symbol,
|
|
370
|
-
|
|
371
|
-
backtest,
|
|
372
|
-
});
|
|
373
|
-
return await ExecutionContextService.runInContext(async () => {
|
|
374
|
-
return await this.exchangeConnectionService.getAveragePrice(symbol);
|
|
375
|
-
}, {
|
|
376
|
-
when,
|
|
377
|
-
backtest,
|
|
2571
|
+
context,
|
|
378
2572
|
});
|
|
2573
|
+
this.strategyValidationService.validate(context.strategyName, METHOD_NAME_RUN$1);
|
|
2574
|
+
this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN$1);
|
|
2575
|
+
return this.liveLogicPublicService.run(symbol, context);
|
|
379
2576
|
};
|
|
380
|
-
|
|
381
|
-
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
const METHOD_NAME_RUN = "backtestGlobalService run";
|
|
2581
|
+
/**
|
|
2582
|
+
* Global service providing access to backtest functionality.
|
|
2583
|
+
*
|
|
2584
|
+
* Simple wrapper around BacktestLogicPublicService for dependency injection.
|
|
2585
|
+
* Used by public API exports.
|
|
2586
|
+
*/
|
|
2587
|
+
class BacktestGlobalService {
|
|
2588
|
+
constructor() {
|
|
2589
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2590
|
+
this.backtestLogicPublicService = inject(TYPES.backtestLogicPublicService);
|
|
2591
|
+
this.strategyValidationService = inject(TYPES.strategyValidationService);
|
|
2592
|
+
this.exchangeValidationService = inject(TYPES.exchangeValidationService);
|
|
2593
|
+
this.frameValidationService = inject(TYPES.frameValidationService);
|
|
2594
|
+
/**
|
|
2595
|
+
* Runs backtest for a symbol with context propagation.
|
|
2596
|
+
*
|
|
2597
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2598
|
+
* @param context - Execution context with strategy, exchange, and frame names
|
|
2599
|
+
* @returns Async generator yielding closed signals with PNL
|
|
2600
|
+
*/
|
|
2601
|
+
this.run = (symbol, context) => {
|
|
2602
|
+
this.loggerService.log(METHOD_NAME_RUN, {
|
|
382
2603
|
symbol,
|
|
383
|
-
|
|
384
|
-
when,
|
|
385
|
-
backtest,
|
|
2604
|
+
context,
|
|
386
2605
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
2606
|
+
this.strategyValidationService.validate(context.strategyName, METHOD_NAME_RUN);
|
|
2607
|
+
this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN);
|
|
2608
|
+
this.frameValidationService.validate(context.frameName, METHOD_NAME_RUN);
|
|
2609
|
+
return this.backtestLogicPublicService.run(symbol, context);
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
const columns$1 = [
|
|
2615
|
+
{
|
|
2616
|
+
key: "signalId",
|
|
2617
|
+
label: "Signal ID",
|
|
2618
|
+
format: (data) => data.signal.id,
|
|
2619
|
+
},
|
|
2620
|
+
{
|
|
2621
|
+
key: "symbol",
|
|
2622
|
+
label: "Symbol",
|
|
2623
|
+
format: (data) => data.signal.symbol,
|
|
2624
|
+
},
|
|
2625
|
+
{
|
|
2626
|
+
key: "position",
|
|
2627
|
+
label: "Position",
|
|
2628
|
+
format: (data) => data.signal.position.toUpperCase(),
|
|
2629
|
+
},
|
|
2630
|
+
{
|
|
2631
|
+
key: "note",
|
|
2632
|
+
label: "Note",
|
|
2633
|
+
format: (data) => data.signal.note ?? "N/A",
|
|
2634
|
+
},
|
|
2635
|
+
{
|
|
2636
|
+
key: "openPrice",
|
|
2637
|
+
label: "Open Price",
|
|
2638
|
+
format: (data) => `${data.signal.priceOpen.toFixed(8)} USD`,
|
|
2639
|
+
},
|
|
2640
|
+
{
|
|
2641
|
+
key: "closePrice",
|
|
2642
|
+
label: "Close Price",
|
|
2643
|
+
format: (data) => `${data.currentPrice.toFixed(8)} USD`,
|
|
2644
|
+
},
|
|
2645
|
+
{
|
|
2646
|
+
key: "takeProfit",
|
|
2647
|
+
label: "Take Profit",
|
|
2648
|
+
format: (data) => `${data.signal.priceTakeProfit.toFixed(8)} USD`,
|
|
2649
|
+
},
|
|
2650
|
+
{
|
|
2651
|
+
key: "stopLoss",
|
|
2652
|
+
label: "Stop Loss",
|
|
2653
|
+
format: (data) => `${data.signal.priceStopLoss.toFixed(8)} USD`,
|
|
2654
|
+
},
|
|
2655
|
+
{
|
|
2656
|
+
key: "pnl",
|
|
2657
|
+
label: "PNL (net)",
|
|
2658
|
+
format: (data) => {
|
|
2659
|
+
const pnlPercentage = data.pnl.pnlPercentage;
|
|
2660
|
+
return `${pnlPercentage > 0 ? "+" : ""}${pnlPercentage.toFixed(2)}%`;
|
|
2661
|
+
},
|
|
2662
|
+
},
|
|
2663
|
+
{
|
|
2664
|
+
key: "closeReason",
|
|
2665
|
+
label: "Close Reason",
|
|
2666
|
+
format: (data) => data.closeReason,
|
|
2667
|
+
},
|
|
2668
|
+
{
|
|
2669
|
+
key: "duration",
|
|
2670
|
+
label: "Duration (min)",
|
|
2671
|
+
format: (data) => {
|
|
2672
|
+
const durationMs = data.closeTimestamp - data.signal.timestamp;
|
|
2673
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
2674
|
+
return `${durationMin}`;
|
|
2675
|
+
},
|
|
2676
|
+
},
|
|
2677
|
+
{
|
|
2678
|
+
key: "openTimestamp",
|
|
2679
|
+
label: "Open Time",
|
|
2680
|
+
format: (data) => new Date(data.signal.timestamp).toISOString(),
|
|
2681
|
+
},
|
|
2682
|
+
{
|
|
2683
|
+
key: "closeTimestamp",
|
|
2684
|
+
label: "Close Time",
|
|
2685
|
+
format: (data) => new Date(data.closeTimestamp).toISOString(),
|
|
2686
|
+
},
|
|
2687
|
+
];
|
|
2688
|
+
/**
|
|
2689
|
+
* Storage class for accumulating closed signals per strategy.
|
|
2690
|
+
* Maintains a list of all closed signals and provides methods to generate reports.
|
|
2691
|
+
*/
|
|
2692
|
+
let ReportStorage$1 = class ReportStorage {
|
|
2693
|
+
constructor() {
|
|
2694
|
+
/** Internal list of all closed signals for this strategy */
|
|
2695
|
+
this._signalList = [];
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* Adds a closed signal to the storage.
|
|
2699
|
+
*
|
|
2700
|
+
* @param data - Closed signal data with PNL and close reason
|
|
2701
|
+
*/
|
|
2702
|
+
addSignal(data) {
|
|
2703
|
+
this._signalList.push(data);
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Generates markdown report with all closed signals for a strategy.
|
|
2707
|
+
*
|
|
2708
|
+
* @param strategyName - Strategy name
|
|
2709
|
+
* @returns Markdown formatted report with all signals
|
|
2710
|
+
*/
|
|
2711
|
+
getReport(strategyName) {
|
|
2712
|
+
if (this._signalList.length === 0) {
|
|
2713
|
+
return str.newline(`# Backtest Report: ${strategyName}`, "", "No signals closed yet.");
|
|
2714
|
+
}
|
|
2715
|
+
const header = columns$1.map((col) => col.label);
|
|
2716
|
+
const rows = this._signalList.map((closedSignal) => columns$1.map((col) => col.format(closedSignal)));
|
|
2717
|
+
const tableData = [header, ...rows];
|
|
2718
|
+
const table = str.table(tableData);
|
|
2719
|
+
return str.newline(`# Backtest Report: ${strategyName}`, "", `Total signals: ${this._signalList.length}`, "", table, "", "", `*Generated: ${new Date().toISOString()}*`);
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Saves strategy report to disk.
|
|
2723
|
+
*
|
|
2724
|
+
* @param strategyName - Strategy name
|
|
2725
|
+
* @param path - Directory path to save report (default: "./logs/backtest")
|
|
2726
|
+
*/
|
|
2727
|
+
async dump(strategyName, path = "./logs/backtest") {
|
|
2728
|
+
const markdown = this.getReport(strategyName);
|
|
2729
|
+
try {
|
|
2730
|
+
const dir = join(process.cwd(), path);
|
|
2731
|
+
await mkdir(dir, { recursive: true });
|
|
2732
|
+
const filename = `${strategyName}.md`;
|
|
2733
|
+
const filepath = join(dir, filename);
|
|
2734
|
+
await writeFile(filepath, markdown, "utf-8");
|
|
2735
|
+
console.log(`Backtest report saved: ${filepath}`);
|
|
2736
|
+
}
|
|
2737
|
+
catch (error) {
|
|
2738
|
+
console.error(`Failed to save markdown report:`, error);
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
/**
|
|
2743
|
+
* Service for generating and saving backtest markdown reports.
|
|
2744
|
+
*
|
|
2745
|
+
* Features:
|
|
2746
|
+
* - Listens to signal events via onTick callback
|
|
2747
|
+
* - Accumulates closed signals per strategy using memoized storage
|
|
2748
|
+
* - Generates markdown tables with detailed signal information
|
|
2749
|
+
* - Saves reports to disk in logs/backtest/{strategyName}.md
|
|
2750
|
+
*
|
|
2751
|
+
* @example
|
|
2752
|
+
* ```typescript
|
|
2753
|
+
* const service = new BacktestMarkdownService();
|
|
2754
|
+
*
|
|
2755
|
+
* // Add to strategy callbacks
|
|
2756
|
+
* addStrategy({
|
|
2757
|
+
* strategyName: "my-strategy",
|
|
2758
|
+
* callbacks: {
|
|
2759
|
+
* onTick: (symbol, result, backtest) => {
|
|
2760
|
+
* service.tick(result);
|
|
2761
|
+
* }
|
|
2762
|
+
* }
|
|
2763
|
+
* });
|
|
2764
|
+
*
|
|
2765
|
+
* // After backtest, generate and save report
|
|
2766
|
+
* await service.saveReport("my-strategy");
|
|
2767
|
+
* ```
|
|
2768
|
+
*/
|
|
2769
|
+
class BacktestMarkdownService {
|
|
2770
|
+
constructor() {
|
|
2771
|
+
/** Logger service for debug output */
|
|
2772
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2773
|
+
/**
|
|
2774
|
+
* Memoized function to get or create ReportStorage for a strategy.
|
|
2775
|
+
* Each strategy gets its own isolated storage instance.
|
|
2776
|
+
*/
|
|
2777
|
+
this.getStorage = memoize(([strategyName]) => `${strategyName}`, () => new ReportStorage$1());
|
|
2778
|
+
/**
|
|
2779
|
+
* Processes tick events and accumulates closed signals.
|
|
2780
|
+
* Should be called from IStrategyCallbacks.onTick.
|
|
2781
|
+
*
|
|
2782
|
+
* Only processes closed signals - opened signals are ignored.
|
|
2783
|
+
*
|
|
2784
|
+
* @param data - Tick result from strategy execution (opened or closed)
|
|
2785
|
+
*
|
|
2786
|
+
* @example
|
|
2787
|
+
* ```typescript
|
|
2788
|
+
* const service = new BacktestMarkdownService();
|
|
2789
|
+
*
|
|
2790
|
+
* callbacks: {
|
|
2791
|
+
* onTick: (symbol, result, backtest) => {
|
|
2792
|
+
* service.tick(result);
|
|
2793
|
+
* }
|
|
2794
|
+
* }
|
|
2795
|
+
* ```
|
|
2796
|
+
*/
|
|
2797
|
+
this.tick = async (data) => {
|
|
2798
|
+
this.loggerService.log("backtestMarkdownService tick", {
|
|
2799
|
+
data,
|
|
392
2800
|
});
|
|
2801
|
+
if (data.action !== "closed") {
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
const storage = this.getStorage(data.strategyName);
|
|
2805
|
+
storage.addSignal(data);
|
|
393
2806
|
};
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
2807
|
+
/**
|
|
2808
|
+
* Generates markdown report with all closed signals for a strategy.
|
|
2809
|
+
* Delegates to ReportStorage.generateReport().
|
|
2810
|
+
*
|
|
2811
|
+
* @param strategyName - Strategy name to generate report for
|
|
2812
|
+
* @returns Markdown formatted report string with table of all closed signals
|
|
2813
|
+
*
|
|
2814
|
+
* @example
|
|
2815
|
+
* ```typescript
|
|
2816
|
+
* const service = new BacktestMarkdownService();
|
|
2817
|
+
* const markdown = service.generateReport("my-strategy");
|
|
2818
|
+
* console.log(markdown);
|
|
2819
|
+
* ```
|
|
2820
|
+
*/
|
|
2821
|
+
this.getReport = async (strategyName) => {
|
|
2822
|
+
this.loggerService.log("backtestMarkdownService getReport", {
|
|
2823
|
+
strategyName,
|
|
400
2824
|
});
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
2825
|
+
const storage = this.getStorage(strategyName);
|
|
2826
|
+
return storage.getReport(strategyName);
|
|
2827
|
+
};
|
|
2828
|
+
/**
|
|
2829
|
+
* Saves strategy report to disk.
|
|
2830
|
+
* Creates directory if it doesn't exist.
|
|
2831
|
+
* Delegates to ReportStorage.dump().
|
|
2832
|
+
*
|
|
2833
|
+
* @param strategyName - Strategy name to save report for
|
|
2834
|
+
* @param path - Directory path to save report (default: "./logs/backtest")
|
|
2835
|
+
*
|
|
2836
|
+
* @example
|
|
2837
|
+
* ```typescript
|
|
2838
|
+
* const service = new BacktestMarkdownService();
|
|
2839
|
+
*
|
|
2840
|
+
* // Save to default path: ./logs/backtest/my-strategy.md
|
|
2841
|
+
* await service.dump("my-strategy");
|
|
2842
|
+
*
|
|
2843
|
+
* // Save to custom path: ./custom/path/my-strategy.md
|
|
2844
|
+
* await service.dump("my-strategy", "./custom/path");
|
|
2845
|
+
* ```
|
|
2846
|
+
*/
|
|
2847
|
+
this.dump = async (strategyName, path = "./logs/backtest") => {
|
|
2848
|
+
this.loggerService.log("backtestMarkdownService dump", {
|
|
2849
|
+
strategyName,
|
|
2850
|
+
path,
|
|
2851
|
+
});
|
|
2852
|
+
const storage = this.getStorage(strategyName);
|
|
2853
|
+
await storage.dump(strategyName, path);
|
|
2854
|
+
};
|
|
2855
|
+
/**
|
|
2856
|
+
* Clears accumulated signal data from storage.
|
|
2857
|
+
* If strategyName is provided, clears only that strategy's data.
|
|
2858
|
+
* If strategyName is omitted, clears all strategies' data.
|
|
2859
|
+
*
|
|
2860
|
+
* @param strategyName - Optional strategy name to clear specific strategy data
|
|
2861
|
+
*
|
|
2862
|
+
* @example
|
|
2863
|
+
* ```typescript
|
|
2864
|
+
* const service = new BacktestMarkdownService();
|
|
2865
|
+
*
|
|
2866
|
+
* // Clear specific strategy data
|
|
2867
|
+
* await service.clear("my-strategy");
|
|
2868
|
+
*
|
|
2869
|
+
* // Clear all strategies' data
|
|
2870
|
+
* await service.clear();
|
|
2871
|
+
* ```
|
|
2872
|
+
*/
|
|
2873
|
+
this.clear = async (strategyName) => {
|
|
2874
|
+
this.loggerService.log("backtestMarkdownService clear", {
|
|
2875
|
+
strategyName,
|
|
406
2876
|
});
|
|
2877
|
+
this.getStorage.clear(strategyName);
|
|
407
2878
|
};
|
|
2879
|
+
/**
|
|
2880
|
+
* Initializes the service by subscribing to backtest signal events.
|
|
2881
|
+
* Uses singleshot to ensure initialization happens only once.
|
|
2882
|
+
* Automatically called on first use.
|
|
2883
|
+
*
|
|
2884
|
+
* @example
|
|
2885
|
+
* ```typescript
|
|
2886
|
+
* const service = new BacktestMarkdownService();
|
|
2887
|
+
* await service.init(); // Subscribe to backtest events
|
|
2888
|
+
* ```
|
|
2889
|
+
*/
|
|
2890
|
+
this.init = singleshot(async () => {
|
|
2891
|
+
this.loggerService.log("backtestMarkdownService init");
|
|
2892
|
+
signalBacktestEmitter.subscribe(this.tick);
|
|
2893
|
+
});
|
|
408
2894
|
}
|
|
409
2895
|
}
|
|
410
2896
|
|
|
411
|
-
|
|
2897
|
+
const columns = [
|
|
2898
|
+
{
|
|
2899
|
+
key: "timestamp",
|
|
2900
|
+
label: "Timestamp",
|
|
2901
|
+
format: (data) => new Date(data.timestamp).toISOString(),
|
|
2902
|
+
},
|
|
2903
|
+
{
|
|
2904
|
+
key: "action",
|
|
2905
|
+
label: "Action",
|
|
2906
|
+
format: (data) => data.action.toUpperCase(),
|
|
2907
|
+
},
|
|
2908
|
+
{
|
|
2909
|
+
key: "symbol",
|
|
2910
|
+
label: "Symbol",
|
|
2911
|
+
format: (data) => data.symbol ?? "N/A",
|
|
2912
|
+
},
|
|
2913
|
+
{
|
|
2914
|
+
key: "signalId",
|
|
2915
|
+
label: "Signal ID",
|
|
2916
|
+
format: (data) => data.signalId ?? "N/A",
|
|
2917
|
+
},
|
|
2918
|
+
{
|
|
2919
|
+
key: "position",
|
|
2920
|
+
label: "Position",
|
|
2921
|
+
format: (data) => data.position?.toUpperCase() ?? "N/A",
|
|
2922
|
+
},
|
|
2923
|
+
{
|
|
2924
|
+
key: "note",
|
|
2925
|
+
label: "Note",
|
|
2926
|
+
format: (data) => data.note ?? "N/A",
|
|
2927
|
+
},
|
|
2928
|
+
{
|
|
2929
|
+
key: "currentPrice",
|
|
2930
|
+
label: "Current Price",
|
|
2931
|
+
format: (data) => `${data.currentPrice.toFixed(8)} USD`,
|
|
2932
|
+
},
|
|
2933
|
+
{
|
|
2934
|
+
key: "openPrice",
|
|
2935
|
+
label: "Open Price",
|
|
2936
|
+
format: (data) => data.openPrice !== undefined ? `${data.openPrice.toFixed(8)} USD` : "N/A",
|
|
2937
|
+
},
|
|
2938
|
+
{
|
|
2939
|
+
key: "takeProfit",
|
|
2940
|
+
label: "Take Profit",
|
|
2941
|
+
format: (data) => data.takeProfit !== undefined
|
|
2942
|
+
? `${data.takeProfit.toFixed(8)} USD`
|
|
2943
|
+
: "N/A",
|
|
2944
|
+
},
|
|
2945
|
+
{
|
|
2946
|
+
key: "stopLoss",
|
|
2947
|
+
label: "Stop Loss",
|
|
2948
|
+
format: (data) => data.stopLoss !== undefined ? `${data.stopLoss.toFixed(8)} USD` : "N/A",
|
|
2949
|
+
},
|
|
2950
|
+
{
|
|
2951
|
+
key: "pnl",
|
|
2952
|
+
label: "PNL (net)",
|
|
2953
|
+
format: (data) => {
|
|
2954
|
+
if (data.pnl === undefined)
|
|
2955
|
+
return "N/A";
|
|
2956
|
+
return `${data.pnl > 0 ? "+" : ""}${data.pnl.toFixed(2)}%`;
|
|
2957
|
+
},
|
|
2958
|
+
},
|
|
2959
|
+
{
|
|
2960
|
+
key: "closeReason",
|
|
2961
|
+
label: "Close Reason",
|
|
2962
|
+
format: (data) => data.closeReason ?? "N/A",
|
|
2963
|
+
},
|
|
2964
|
+
{
|
|
2965
|
+
key: "duration",
|
|
2966
|
+
label: "Duration (min)",
|
|
2967
|
+
format: (data) => data.duration !== undefined ? `${data.duration}` : "N/A",
|
|
2968
|
+
},
|
|
2969
|
+
];
|
|
2970
|
+
/** Maximum number of events to store in live trading reports */
|
|
2971
|
+
const MAX_EVENTS = 250;
|
|
2972
|
+
/**
|
|
2973
|
+
* Storage class for accumulating all tick events per strategy.
|
|
2974
|
+
* Maintains a chronological list of all events (idle, opened, active, closed).
|
|
2975
|
+
*/
|
|
2976
|
+
class ReportStorage {
|
|
2977
|
+
constructor() {
|
|
2978
|
+
/** Internal list of all tick events for this strategy */
|
|
2979
|
+
this._eventList = [];
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* Adds an idle event to the storage.
|
|
2983
|
+
* Replaces the last idle event only if there are no opened/active events after it.
|
|
2984
|
+
*
|
|
2985
|
+
* @param currentPrice - Current market price
|
|
2986
|
+
*/
|
|
2987
|
+
addIdleEvent(currentPrice) {
|
|
2988
|
+
const newEvent = {
|
|
2989
|
+
timestamp: Date.now(),
|
|
2990
|
+
action: "idle",
|
|
2991
|
+
currentPrice,
|
|
2992
|
+
};
|
|
2993
|
+
const lastIdleIndex = this._eventList.findLastIndex((event) => event.action === "idle");
|
|
2994
|
+
const canReplaceLastIdle = lastIdleIndex !== -1 &&
|
|
2995
|
+
!this._eventList
|
|
2996
|
+
.slice(lastIdleIndex + 1)
|
|
2997
|
+
.some((event) => event.action === "opened" || event.action === "active");
|
|
2998
|
+
if (canReplaceLastIdle) {
|
|
2999
|
+
this._eventList[lastIdleIndex] = newEvent;
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
{
|
|
3003
|
+
this._eventList.push(newEvent);
|
|
3004
|
+
if (this._eventList.length > MAX_EVENTS) {
|
|
3005
|
+
this._eventList.shift();
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* Adds an opened event to the storage.
|
|
3011
|
+
*
|
|
3012
|
+
* @param data - Opened tick result
|
|
3013
|
+
*/
|
|
3014
|
+
addOpenedEvent(data) {
|
|
3015
|
+
this._eventList.push({
|
|
3016
|
+
timestamp: data.signal.timestamp,
|
|
3017
|
+
action: "opened",
|
|
3018
|
+
symbol: data.signal.symbol,
|
|
3019
|
+
signalId: data.signal.id,
|
|
3020
|
+
position: data.signal.position,
|
|
3021
|
+
note: data.signal.note,
|
|
3022
|
+
currentPrice: data.signal.priceOpen,
|
|
3023
|
+
openPrice: data.signal.priceOpen,
|
|
3024
|
+
takeProfit: data.signal.priceTakeProfit,
|
|
3025
|
+
stopLoss: data.signal.priceStopLoss,
|
|
3026
|
+
});
|
|
3027
|
+
// Trim queue if exceeded MAX_EVENTS
|
|
3028
|
+
if (this._eventList.length > MAX_EVENTS) {
|
|
3029
|
+
this._eventList.shift();
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Updates or adds an active event to the storage.
|
|
3034
|
+
* Replaces the previous event with the same signalId.
|
|
3035
|
+
*
|
|
3036
|
+
* @param data - Active tick result
|
|
3037
|
+
*/
|
|
3038
|
+
addActiveEvent(data) {
|
|
3039
|
+
// Find existing event with the same signalId
|
|
3040
|
+
const existingIndex = this._eventList.findIndex((event) => event.signalId === data.signal.id);
|
|
3041
|
+
const newEvent = {
|
|
3042
|
+
timestamp: Date.now(),
|
|
3043
|
+
action: "active",
|
|
3044
|
+
symbol: data.signal.symbol,
|
|
3045
|
+
signalId: data.signal.id,
|
|
3046
|
+
position: data.signal.position,
|
|
3047
|
+
note: data.signal.note,
|
|
3048
|
+
currentPrice: data.currentPrice,
|
|
3049
|
+
openPrice: data.signal.priceOpen,
|
|
3050
|
+
takeProfit: data.signal.priceTakeProfit,
|
|
3051
|
+
stopLoss: data.signal.priceStopLoss,
|
|
3052
|
+
};
|
|
3053
|
+
// Replace existing event or add new one
|
|
3054
|
+
if (existingIndex !== -1) {
|
|
3055
|
+
this._eventList[existingIndex] = newEvent;
|
|
3056
|
+
}
|
|
3057
|
+
else {
|
|
3058
|
+
this._eventList.push(newEvent);
|
|
3059
|
+
// Trim queue if exceeded MAX_EVENTS
|
|
3060
|
+
if (this._eventList.length > MAX_EVENTS) {
|
|
3061
|
+
this._eventList.shift();
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* Updates or adds a closed event to the storage.
|
|
3067
|
+
* Replaces the previous event with the same signalId.
|
|
3068
|
+
*
|
|
3069
|
+
* @param data - Closed tick result
|
|
3070
|
+
*/
|
|
3071
|
+
addClosedEvent(data) {
|
|
3072
|
+
const durationMs = data.closeTimestamp - data.signal.timestamp;
|
|
3073
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
3074
|
+
// Find existing event with the same signalId
|
|
3075
|
+
const existingIndex = this._eventList.findIndex((event) => event.signalId === data.signal.id);
|
|
3076
|
+
const newEvent = {
|
|
3077
|
+
timestamp: data.closeTimestamp,
|
|
3078
|
+
action: "closed",
|
|
3079
|
+
symbol: data.signal.symbol,
|
|
3080
|
+
signalId: data.signal.id,
|
|
3081
|
+
position: data.signal.position,
|
|
3082
|
+
note: data.signal.note,
|
|
3083
|
+
currentPrice: data.currentPrice,
|
|
3084
|
+
openPrice: data.signal.priceOpen,
|
|
3085
|
+
takeProfit: data.signal.priceTakeProfit,
|
|
3086
|
+
stopLoss: data.signal.priceStopLoss,
|
|
3087
|
+
pnl: data.pnl.pnlPercentage,
|
|
3088
|
+
closeReason: data.closeReason,
|
|
3089
|
+
duration: durationMin,
|
|
3090
|
+
};
|
|
3091
|
+
// Replace existing event or add new one
|
|
3092
|
+
if (existingIndex !== -1) {
|
|
3093
|
+
this._eventList[existingIndex] = newEvent;
|
|
3094
|
+
}
|
|
3095
|
+
else {
|
|
3096
|
+
this._eventList.push(newEvent);
|
|
3097
|
+
// Trim queue if exceeded MAX_EVENTS
|
|
3098
|
+
if (this._eventList.length > MAX_EVENTS) {
|
|
3099
|
+
this._eventList.shift();
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
/**
|
|
3104
|
+
* Generates markdown report with all tick events for a strategy.
|
|
3105
|
+
*
|
|
3106
|
+
* @param strategyName - Strategy name
|
|
3107
|
+
* @returns Markdown formatted report with all events
|
|
3108
|
+
*/
|
|
3109
|
+
getReport(strategyName) {
|
|
3110
|
+
if (this._eventList.length === 0) {
|
|
3111
|
+
return str.newline(`# Live Trading Report: ${strategyName}`, "", "No events recorded yet.");
|
|
3112
|
+
}
|
|
3113
|
+
const header = columns.map((col) => col.label);
|
|
3114
|
+
const rows = this._eventList.map((event) => columns.map((col) => col.format(event)));
|
|
3115
|
+
const tableData = [header, ...rows];
|
|
3116
|
+
const table = str.table(tableData);
|
|
3117
|
+
// Calculate statistics
|
|
3118
|
+
const closedEvents = this._eventList.filter((e) => e.action === "closed");
|
|
3119
|
+
const totalClosed = closedEvents.length;
|
|
3120
|
+
const winCount = closedEvents.filter((e) => e.pnl && e.pnl > 0).length;
|
|
3121
|
+
const lossCount = closedEvents.filter((e) => e.pnl && e.pnl < 0).length;
|
|
3122
|
+
const avgPnl = totalClosed > 0
|
|
3123
|
+
? closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0) / totalClosed
|
|
3124
|
+
: 0;
|
|
3125
|
+
return str.newline(`# Live Trading Report: ${strategyName}`, "", `Total events: ${this._eventList.length}`, `Closed signals: ${totalClosed}`, totalClosed > 0
|
|
3126
|
+
? `Win rate: ${((winCount / totalClosed) * 100).toFixed(2)}% (${winCount}W / ${lossCount}L)`
|
|
3127
|
+
: "", totalClosed > 0
|
|
3128
|
+
? `Average PNL: ${avgPnl > 0 ? "+" : ""}${avgPnl.toFixed(2)}%`
|
|
3129
|
+
: "", "", table, "", "", `*Generated: ${new Date().toISOString()}*`);
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Saves strategy report to disk.
|
|
3133
|
+
*
|
|
3134
|
+
* @param strategyName - Strategy name
|
|
3135
|
+
* @param path - Directory path to save report (default: "./logs/live")
|
|
3136
|
+
*/
|
|
3137
|
+
async dump(strategyName, path = "./logs/live") {
|
|
3138
|
+
const markdown = this.getReport(strategyName);
|
|
3139
|
+
try {
|
|
3140
|
+
const dir = join(process.cwd(), path);
|
|
3141
|
+
await mkdir(dir, { recursive: true });
|
|
3142
|
+
const filename = `${strategyName}.md`;
|
|
3143
|
+
const filepath = join(dir, filename);
|
|
3144
|
+
await writeFile(filepath, markdown, "utf-8");
|
|
3145
|
+
console.log(`Live trading report saved: ${filepath}`);
|
|
3146
|
+
}
|
|
3147
|
+
catch (error) {
|
|
3148
|
+
console.error(`Failed to save markdown report:`, error);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Service for generating and saving live trading markdown reports.
|
|
3154
|
+
*
|
|
3155
|
+
* Features:
|
|
3156
|
+
* - Listens to all signal events via onTick callback
|
|
3157
|
+
* - Accumulates all events (idle, opened, active, closed) per strategy
|
|
3158
|
+
* - Generates markdown tables with detailed event information
|
|
3159
|
+
* - Provides trading statistics (win rate, average PNL)
|
|
3160
|
+
* - Saves reports to disk in logs/live/{strategyName}.md
|
|
3161
|
+
*
|
|
3162
|
+
* @example
|
|
3163
|
+
* ```typescript
|
|
3164
|
+
* const service = new LiveMarkdownService();
|
|
3165
|
+
*
|
|
3166
|
+
* // Add to strategy callbacks
|
|
3167
|
+
* addStrategy({
|
|
3168
|
+
* strategyName: "my-strategy",
|
|
3169
|
+
* callbacks: {
|
|
3170
|
+
* onTick: (symbol, result, backtest) => {
|
|
3171
|
+
* if (!backtest) {
|
|
3172
|
+
* service.tick(result);
|
|
3173
|
+
* }
|
|
3174
|
+
* }
|
|
3175
|
+
* }
|
|
3176
|
+
* });
|
|
3177
|
+
*
|
|
3178
|
+
* // Later: generate and save report
|
|
3179
|
+
* await service.dump("my-strategy");
|
|
3180
|
+
* ```
|
|
3181
|
+
*/
|
|
3182
|
+
class LiveMarkdownService {
|
|
412
3183
|
constructor() {
|
|
3184
|
+
/** Logger service for debug output */
|
|
413
3185
|
this.loggerService = inject(TYPES.loggerService);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
3186
|
+
/**
|
|
3187
|
+
* Memoized function to get or create ReportStorage for a strategy.
|
|
3188
|
+
* Each strategy gets its own isolated storage instance.
|
|
3189
|
+
*/
|
|
3190
|
+
this.getStorage = memoize(([strategyName]) => `${strategyName}`, () => new ReportStorage());
|
|
3191
|
+
/**
|
|
3192
|
+
* Processes tick events and accumulates all event types.
|
|
3193
|
+
* Should be called from IStrategyCallbacks.onTick.
|
|
3194
|
+
*
|
|
3195
|
+
* Processes all event types: idle, opened, active, closed.
|
|
3196
|
+
*
|
|
3197
|
+
* @param data - Tick result from strategy execution
|
|
3198
|
+
*
|
|
3199
|
+
* @example
|
|
3200
|
+
* ```typescript
|
|
3201
|
+
* const service = new LiveMarkdownService();
|
|
3202
|
+
*
|
|
3203
|
+
* callbacks: {
|
|
3204
|
+
* onTick: (symbol, result, backtest) => {
|
|
3205
|
+
* if (!backtest) {
|
|
3206
|
+
* service.tick(result);
|
|
3207
|
+
* }
|
|
3208
|
+
* }
|
|
3209
|
+
* }
|
|
3210
|
+
* ```
|
|
3211
|
+
*/
|
|
3212
|
+
this.tick = async (data) => {
|
|
3213
|
+
this.loggerService.log("liveMarkdownService tick", {
|
|
3214
|
+
data,
|
|
420
3215
|
});
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
3216
|
+
const storage = this.getStorage(data.strategyName);
|
|
3217
|
+
if (data.action === "idle") {
|
|
3218
|
+
storage.addIdleEvent(data.currentPrice);
|
|
3219
|
+
}
|
|
3220
|
+
else if (data.action === "opened") {
|
|
3221
|
+
storage.addOpenedEvent(data);
|
|
3222
|
+
}
|
|
3223
|
+
else if (data.action === "active") {
|
|
3224
|
+
storage.addActiveEvent(data);
|
|
3225
|
+
}
|
|
3226
|
+
else if (data.action === "closed") {
|
|
3227
|
+
storage.addClosedEvent(data);
|
|
3228
|
+
}
|
|
3229
|
+
};
|
|
3230
|
+
/**
|
|
3231
|
+
* Generates markdown report with all events for a strategy.
|
|
3232
|
+
* Delegates to ReportStorage.getReport().
|
|
3233
|
+
*
|
|
3234
|
+
* @param strategyName - Strategy name to generate report for
|
|
3235
|
+
* @returns Markdown formatted report string with table of all events
|
|
3236
|
+
*
|
|
3237
|
+
* @example
|
|
3238
|
+
* ```typescript
|
|
3239
|
+
* const service = new LiveMarkdownService();
|
|
3240
|
+
* const markdown = await service.getReport("my-strategy");
|
|
3241
|
+
* console.log(markdown);
|
|
3242
|
+
* ```
|
|
3243
|
+
*/
|
|
3244
|
+
this.getReport = async (strategyName) => {
|
|
3245
|
+
this.loggerService.log("liveMarkdownService getReport", {
|
|
3246
|
+
strategyName,
|
|
3247
|
+
});
|
|
3248
|
+
const storage = this.getStorage(strategyName);
|
|
3249
|
+
return storage.getReport(strategyName);
|
|
3250
|
+
};
|
|
3251
|
+
/**
|
|
3252
|
+
* Saves strategy report to disk.
|
|
3253
|
+
* Creates directory if it doesn't exist.
|
|
3254
|
+
* Delegates to ReportStorage.dump().
|
|
3255
|
+
*
|
|
3256
|
+
* @param strategyName - Strategy name to save report for
|
|
3257
|
+
* @param path - Directory path to save report (default: "./logs/live")
|
|
3258
|
+
*
|
|
3259
|
+
* @example
|
|
3260
|
+
* ```typescript
|
|
3261
|
+
* const service = new LiveMarkdownService();
|
|
3262
|
+
*
|
|
3263
|
+
* // Save to default path: ./logs/live/my-strategy.md
|
|
3264
|
+
* await service.dump("my-strategy");
|
|
3265
|
+
*
|
|
3266
|
+
* // Save to custom path: ./custom/path/my-strategy.md
|
|
3267
|
+
* await service.dump("my-strategy", "./custom/path");
|
|
3268
|
+
* ```
|
|
3269
|
+
*/
|
|
3270
|
+
this.dump = async (strategyName, path = "./logs/live") => {
|
|
3271
|
+
this.loggerService.log("liveMarkdownService dump", {
|
|
3272
|
+
strategyName,
|
|
3273
|
+
path,
|
|
3274
|
+
});
|
|
3275
|
+
const storage = this.getStorage(strategyName);
|
|
3276
|
+
await storage.dump(strategyName, path);
|
|
3277
|
+
};
|
|
3278
|
+
/**
|
|
3279
|
+
* Clears accumulated event data from storage.
|
|
3280
|
+
* If strategyName is provided, clears only that strategy's data.
|
|
3281
|
+
* If strategyName is omitted, clears all strategies' data.
|
|
3282
|
+
*
|
|
3283
|
+
* @param strategyName - Optional strategy name to clear specific strategy data
|
|
3284
|
+
*
|
|
3285
|
+
* @example
|
|
3286
|
+
* ```typescript
|
|
3287
|
+
* const service = new LiveMarkdownService();
|
|
3288
|
+
*
|
|
3289
|
+
* // Clear specific strategy data
|
|
3290
|
+
* await service.clear("my-strategy");
|
|
3291
|
+
*
|
|
3292
|
+
* // Clear all strategies' data
|
|
3293
|
+
* await service.clear();
|
|
3294
|
+
* ```
|
|
3295
|
+
*/
|
|
3296
|
+
this.clear = async (strategyName) => {
|
|
3297
|
+
this.loggerService.log("liveMarkdownService clear", {
|
|
3298
|
+
strategyName,
|
|
426
3299
|
});
|
|
3300
|
+
this.getStorage.clear(strategyName);
|
|
427
3301
|
};
|
|
3302
|
+
/**
|
|
3303
|
+
* Initializes the service by subscribing to live signal events.
|
|
3304
|
+
* Uses singleshot to ensure initialization happens only once.
|
|
3305
|
+
* Automatically called on first use.
|
|
3306
|
+
*
|
|
3307
|
+
* @example
|
|
3308
|
+
* ```typescript
|
|
3309
|
+
* const service = new LiveMarkdownService();
|
|
3310
|
+
* await service.init(); // Subscribe to live events
|
|
3311
|
+
* ```
|
|
3312
|
+
*/
|
|
3313
|
+
this.init = singleshot(async () => {
|
|
3314
|
+
this.loggerService.log("liveMarkdownService init");
|
|
3315
|
+
signalLiveEmitter.subscribe(this.tick);
|
|
3316
|
+
});
|
|
428
3317
|
}
|
|
429
3318
|
}
|
|
430
3319
|
|
|
431
|
-
|
|
3320
|
+
/**
|
|
3321
|
+
* @class ExchangeValidationService
|
|
3322
|
+
* Service for managing and validating exchange configurations
|
|
3323
|
+
*/
|
|
3324
|
+
class ExchangeValidationService {
|
|
432
3325
|
constructor() {
|
|
3326
|
+
/**
|
|
3327
|
+
* @private
|
|
3328
|
+
* @readonly
|
|
3329
|
+
* Injected logger service instance
|
|
3330
|
+
*/
|
|
433
3331
|
this.loggerService = inject(TYPES.loggerService);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
3332
|
+
/**
|
|
3333
|
+
* @private
|
|
3334
|
+
* Map storing exchange schemas by exchange name
|
|
3335
|
+
*/
|
|
3336
|
+
this._exchangeMap = new Map();
|
|
3337
|
+
/**
|
|
3338
|
+
* Adds an exchange schema to the validation service
|
|
3339
|
+
* @public
|
|
3340
|
+
* @throws {Error} If exchangeName already exists
|
|
3341
|
+
*/
|
|
3342
|
+
this.addExchange = (exchangeName, exchangeSchema) => {
|
|
3343
|
+
this.loggerService.log("exchangeValidationService addExchange", {
|
|
3344
|
+
exchangeName,
|
|
3345
|
+
exchangeSchema,
|
|
3346
|
+
});
|
|
3347
|
+
if (this._exchangeMap.has(exchangeName)) {
|
|
3348
|
+
throw new Error(`exchange ${exchangeName} already exist`);
|
|
438
3349
|
}
|
|
439
|
-
|
|
440
|
-
};
|
|
441
|
-
this.addSchema = (exchangeSchema) => {
|
|
442
|
-
this.loggerService.log("exchangeSchemaService addSchema");
|
|
443
|
-
this._exchangeSchema = exchangeSchema;
|
|
3350
|
+
this._exchangeMap.set(exchangeName, exchangeSchema);
|
|
444
3351
|
};
|
|
3352
|
+
/**
|
|
3353
|
+
* Validates the existence of an exchange
|
|
3354
|
+
* @public
|
|
3355
|
+
* @throws {Error} If exchangeName is not found
|
|
3356
|
+
* Memoized function to cache validation results
|
|
3357
|
+
*/
|
|
3358
|
+
this.validate = memoize(([exchangeName]) => exchangeName, (exchangeName, source) => {
|
|
3359
|
+
this.loggerService.log("exchangeValidationService validate", {
|
|
3360
|
+
exchangeName,
|
|
3361
|
+
source,
|
|
3362
|
+
});
|
|
3363
|
+
const exchange = this._exchangeMap.get(exchangeName);
|
|
3364
|
+
if (!exchange) {
|
|
3365
|
+
throw new Error(`exchange ${exchangeName} not found source=${source}`);
|
|
3366
|
+
}
|
|
3367
|
+
return true;
|
|
3368
|
+
});
|
|
445
3369
|
}
|
|
446
3370
|
}
|
|
447
3371
|
|
|
448
|
-
|
|
3372
|
+
/**
|
|
3373
|
+
* @class StrategyValidationService
|
|
3374
|
+
* Service for managing and validating strategy configurations
|
|
3375
|
+
*/
|
|
3376
|
+
class StrategyValidationService {
|
|
449
3377
|
constructor() {
|
|
3378
|
+
/**
|
|
3379
|
+
* @private
|
|
3380
|
+
* @readonly
|
|
3381
|
+
* Injected logger service instance
|
|
3382
|
+
*/
|
|
450
3383
|
this.loggerService = inject(TYPES.loggerService);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
3384
|
+
/**
|
|
3385
|
+
* @private
|
|
3386
|
+
* Map storing strategy schemas by strategy name
|
|
3387
|
+
*/
|
|
3388
|
+
this._strategyMap = new Map();
|
|
3389
|
+
/**
|
|
3390
|
+
* Adds a strategy schema to the validation service
|
|
3391
|
+
* @public
|
|
3392
|
+
* @throws {Error} If strategyName already exists
|
|
3393
|
+
*/
|
|
3394
|
+
this.addStrategy = (strategyName, strategySchema) => {
|
|
3395
|
+
this.loggerService.log("strategyValidationService addStrategy", {
|
|
3396
|
+
strategyName,
|
|
3397
|
+
strategySchema,
|
|
3398
|
+
});
|
|
3399
|
+
if (this._strategyMap.has(strategyName)) {
|
|
3400
|
+
throw new Error(`strategy ${strategyName} already exist`);
|
|
455
3401
|
}
|
|
456
|
-
|
|
3402
|
+
this._strategyMap.set(strategyName, strategySchema);
|
|
457
3403
|
};
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
3404
|
+
/**
|
|
3405
|
+
* Validates the existence of a strategy
|
|
3406
|
+
* @public
|
|
3407
|
+
* @throws {Error} If strategyName is not found
|
|
3408
|
+
* Memoized function to cache validation results
|
|
3409
|
+
*/
|
|
3410
|
+
this.validate = memoize(([strategyName]) => strategyName, (strategyName, source) => {
|
|
3411
|
+
this.loggerService.log("strategyValidationService validate", {
|
|
3412
|
+
strategyName,
|
|
3413
|
+
source,
|
|
3414
|
+
});
|
|
3415
|
+
const strategy = this._strategyMap.get(strategyName);
|
|
3416
|
+
if (!strategy) {
|
|
3417
|
+
throw new Error(`strategy ${strategyName} not found source=${source}`);
|
|
3418
|
+
}
|
|
3419
|
+
return true;
|
|
3420
|
+
});
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
/**
|
|
3425
|
+
* @class FrameValidationService
|
|
3426
|
+
* Service for managing and validating frame configurations
|
|
3427
|
+
*/
|
|
3428
|
+
class FrameValidationService {
|
|
3429
|
+
constructor() {
|
|
3430
|
+
/**
|
|
3431
|
+
* @private
|
|
3432
|
+
* @readonly
|
|
3433
|
+
* Injected logger service instance
|
|
3434
|
+
*/
|
|
3435
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
3436
|
+
/**
|
|
3437
|
+
* @private
|
|
3438
|
+
* Map storing frame schemas by frame name
|
|
3439
|
+
*/
|
|
3440
|
+
this._frameMap = new Map();
|
|
3441
|
+
/**
|
|
3442
|
+
* Adds a frame schema to the validation service
|
|
3443
|
+
* @public
|
|
3444
|
+
* @throws {Error} If frameName already exists
|
|
3445
|
+
*/
|
|
3446
|
+
this.addFrame = (frameName, frameSchema) => {
|
|
3447
|
+
this.loggerService.log("frameValidationService addFrame", {
|
|
3448
|
+
frameName,
|
|
3449
|
+
frameSchema,
|
|
3450
|
+
});
|
|
3451
|
+
if (this._frameMap.has(frameName)) {
|
|
3452
|
+
throw new Error(`frame ${frameName} already exist`);
|
|
3453
|
+
}
|
|
3454
|
+
this._frameMap.set(frameName, frameSchema);
|
|
461
3455
|
};
|
|
3456
|
+
/**
|
|
3457
|
+
* Validates the existence of a frame
|
|
3458
|
+
* @public
|
|
3459
|
+
* @throws {Error} If frameName is not found
|
|
3460
|
+
* Memoized function to cache validation results
|
|
3461
|
+
*/
|
|
3462
|
+
this.validate = memoize(([frameName]) => frameName, (frameName, source) => {
|
|
3463
|
+
this.loggerService.log("frameValidationService validate", {
|
|
3464
|
+
frameName,
|
|
3465
|
+
source,
|
|
3466
|
+
});
|
|
3467
|
+
const frame = this._frameMap.get(frameName);
|
|
3468
|
+
if (!frame) {
|
|
3469
|
+
throw new Error(`frame ${frameName} not found source=${source}`);
|
|
3470
|
+
}
|
|
3471
|
+
return true;
|
|
3472
|
+
});
|
|
462
3473
|
}
|
|
463
3474
|
}
|
|
464
3475
|
|
|
@@ -467,18 +3478,41 @@ class StrategySchemaService {
|
|
|
467
3478
|
}
|
|
468
3479
|
{
|
|
469
3480
|
provide(TYPES.executionContextService, () => new ExecutionContextService());
|
|
3481
|
+
provide(TYPES.methodContextService, () => new MethodContextService());
|
|
470
3482
|
}
|
|
471
3483
|
{
|
|
472
3484
|
provide(TYPES.exchangeConnectionService, () => new ExchangeConnectionService());
|
|
473
3485
|
provide(TYPES.strategyConnectionService, () => new StrategyConnectionService());
|
|
3486
|
+
provide(TYPES.frameConnectionService, () => new FrameConnectionService());
|
|
474
3487
|
}
|
|
475
3488
|
{
|
|
476
3489
|
provide(TYPES.exchangeSchemaService, () => new ExchangeSchemaService());
|
|
477
3490
|
provide(TYPES.strategySchemaService, () => new StrategySchemaService());
|
|
3491
|
+
provide(TYPES.frameSchemaService, () => new FrameSchemaService());
|
|
3492
|
+
}
|
|
3493
|
+
{
|
|
3494
|
+
provide(TYPES.exchangeGlobalService, () => new ExchangeGlobalService());
|
|
3495
|
+
provide(TYPES.strategyGlobalService, () => new StrategyGlobalService());
|
|
3496
|
+
provide(TYPES.frameGlobalService, () => new FrameGlobalService());
|
|
3497
|
+
provide(TYPES.liveGlobalService, () => new LiveGlobalService());
|
|
3498
|
+
provide(TYPES.backtestGlobalService, () => new BacktestGlobalService());
|
|
3499
|
+
}
|
|
3500
|
+
{
|
|
3501
|
+
provide(TYPES.backtestLogicPrivateService, () => new BacktestLogicPrivateService());
|
|
3502
|
+
provide(TYPES.liveLogicPrivateService, () => new LiveLogicPrivateService());
|
|
3503
|
+
}
|
|
3504
|
+
{
|
|
3505
|
+
provide(TYPES.backtestLogicPublicService, () => new BacktestLogicPublicService());
|
|
3506
|
+
provide(TYPES.liveLogicPublicService, () => new LiveLogicPublicService());
|
|
3507
|
+
}
|
|
3508
|
+
{
|
|
3509
|
+
provide(TYPES.backtestMarkdownService, () => new BacktestMarkdownService());
|
|
3510
|
+
provide(TYPES.liveMarkdownService, () => new LiveMarkdownService());
|
|
478
3511
|
}
|
|
479
3512
|
{
|
|
480
|
-
provide(TYPES.
|
|
481
|
-
provide(TYPES.
|
|
3513
|
+
provide(TYPES.exchangeValidationService, () => new ExchangeValidationService());
|
|
3514
|
+
provide(TYPES.strategyValidationService, () => new StrategyValidationService());
|
|
3515
|
+
provide(TYPES.frameValidationService, () => new FrameValidationService());
|
|
482
3516
|
}
|
|
483
3517
|
|
|
484
3518
|
const baseServices = {
|
|
@@ -486,159 +3520,840 @@ const baseServices = {
|
|
|
486
3520
|
};
|
|
487
3521
|
const contextServices = {
|
|
488
3522
|
executionContextService: inject(TYPES.executionContextService),
|
|
3523
|
+
methodContextService: inject(TYPES.methodContextService),
|
|
489
3524
|
};
|
|
490
3525
|
const connectionServices = {
|
|
491
3526
|
exchangeConnectionService: inject(TYPES.exchangeConnectionService),
|
|
492
3527
|
strategyConnectionService: inject(TYPES.strategyConnectionService),
|
|
3528
|
+
frameConnectionService: inject(TYPES.frameConnectionService),
|
|
493
3529
|
};
|
|
494
3530
|
const schemaServices = {
|
|
495
3531
|
exchangeSchemaService: inject(TYPES.exchangeSchemaService),
|
|
496
3532
|
strategySchemaService: inject(TYPES.strategySchemaService),
|
|
3533
|
+
frameSchemaService: inject(TYPES.frameSchemaService),
|
|
3534
|
+
};
|
|
3535
|
+
const globalServices = {
|
|
3536
|
+
exchangeGlobalService: inject(TYPES.exchangeGlobalService),
|
|
3537
|
+
strategyGlobalService: inject(TYPES.strategyGlobalService),
|
|
3538
|
+
frameGlobalService: inject(TYPES.frameGlobalService),
|
|
3539
|
+
liveGlobalService: inject(TYPES.liveGlobalService),
|
|
3540
|
+
backtestGlobalService: inject(TYPES.backtestGlobalService),
|
|
497
3541
|
};
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
3542
|
+
const logicPrivateServices = {
|
|
3543
|
+
backtestLogicPrivateService: inject(TYPES.backtestLogicPrivateService),
|
|
3544
|
+
liveLogicPrivateService: inject(TYPES.liveLogicPrivateService),
|
|
3545
|
+
};
|
|
3546
|
+
const logicPublicServices = {
|
|
3547
|
+
backtestLogicPublicService: inject(TYPES.backtestLogicPublicService),
|
|
3548
|
+
liveLogicPublicService: inject(TYPES.liveLogicPublicService),
|
|
3549
|
+
};
|
|
3550
|
+
const markdownServices = {
|
|
3551
|
+
backtestMarkdownService: inject(TYPES.backtestMarkdownService),
|
|
3552
|
+
liveMarkdownService: inject(TYPES.liveMarkdownService),
|
|
3553
|
+
};
|
|
3554
|
+
const validationServices = {
|
|
3555
|
+
exchangeValidationService: inject(TYPES.exchangeValidationService),
|
|
3556
|
+
strategyValidationService: inject(TYPES.strategyValidationService),
|
|
3557
|
+
frameValidationService: inject(TYPES.frameValidationService),
|
|
501
3558
|
};
|
|
502
3559
|
const backtest = {
|
|
503
3560
|
...baseServices,
|
|
504
3561
|
...contextServices,
|
|
505
3562
|
...connectionServices,
|
|
506
3563
|
...schemaServices,
|
|
507
|
-
...
|
|
3564
|
+
...globalServices,
|
|
3565
|
+
...logicPrivateServices,
|
|
3566
|
+
...logicPublicServices,
|
|
3567
|
+
...markdownServices,
|
|
3568
|
+
...validationServices,
|
|
508
3569
|
};
|
|
509
3570
|
init();
|
|
3571
|
+
var backtest$1 = backtest;
|
|
510
3572
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
3573
|
+
/**
|
|
3574
|
+
* Sets custom logger implementation for the framework.
|
|
3575
|
+
*
|
|
3576
|
+
* All log messages from internal services will be forwarded to the provided logger
|
|
3577
|
+
* with automatic context injection (strategyName, exchangeName, symbol, etc.).
|
|
3578
|
+
*
|
|
3579
|
+
* @param logger - Custom logger implementing ILogger interface
|
|
3580
|
+
*
|
|
3581
|
+
* @example
|
|
3582
|
+
* ```typescript
|
|
3583
|
+
* setLogger({
|
|
3584
|
+
* log: (topic, ...args) => console.log(topic, args),
|
|
3585
|
+
* debug: (topic, ...args) => console.debug(topic, args),
|
|
3586
|
+
* info: (topic, ...args) => console.info(topic, args),
|
|
3587
|
+
* });
|
|
3588
|
+
* ```
|
|
3589
|
+
*/
|
|
3590
|
+
async function setLogger(logger) {
|
|
3591
|
+
backtest$1.loggerService.setLogger(logger);
|
|
516
3592
|
}
|
|
517
3593
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
3594
|
+
const ADD_STRATEGY_METHOD_NAME = "add.addStrategy";
|
|
3595
|
+
const ADD_EXCHANGE_METHOD_NAME = "add.addExchange";
|
|
3596
|
+
const ADD_FRAME_METHOD_NAME = "add.addFrame";
|
|
3597
|
+
/**
|
|
3598
|
+
* Registers a trading strategy in the framework.
|
|
3599
|
+
*
|
|
3600
|
+
* The strategy will be validated for:
|
|
3601
|
+
* - Signal validation (prices, TP/SL logic, timestamps)
|
|
3602
|
+
* - Interval throttling (prevents signal spam)
|
|
3603
|
+
* - Crash-safe persistence in live mode
|
|
3604
|
+
*
|
|
3605
|
+
* @param strategySchema - Strategy configuration object
|
|
3606
|
+
* @param strategySchema.strategyName - Unique strategy identifier
|
|
3607
|
+
* @param strategySchema.interval - Signal generation interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h")
|
|
3608
|
+
* @param strategySchema.getSignal - Async function that generates trading signals
|
|
3609
|
+
* @param strategySchema.callbacks - Optional lifecycle callbacks (onOpen, onClose)
|
|
3610
|
+
*
|
|
3611
|
+
* @example
|
|
3612
|
+
* ```typescript
|
|
3613
|
+
* addStrategy({
|
|
3614
|
+
* strategyName: "my-strategy",
|
|
3615
|
+
* interval: "5m",
|
|
3616
|
+
* getSignal: async (symbol) => ({
|
|
3617
|
+
* position: "long",
|
|
3618
|
+
* priceOpen: 50000,
|
|
3619
|
+
* priceTakeProfit: 51000,
|
|
3620
|
+
* priceStopLoss: 49000,
|
|
3621
|
+
* minuteEstimatedTime: 60,
|
|
3622
|
+
* timestamp: Date.now(),
|
|
3623
|
+
* }),
|
|
3624
|
+
* callbacks: {
|
|
3625
|
+
* onOpen: (backtest, symbol, signal) => console.log("Signal opened"),
|
|
3626
|
+
* onClose: (backtest, symbol, priceClose, signal) => console.log("Signal closed"),
|
|
3627
|
+
* },
|
|
3628
|
+
* });
|
|
3629
|
+
* ```
|
|
3630
|
+
*/
|
|
3631
|
+
function addStrategy(strategySchema) {
|
|
3632
|
+
backtest$1.loggerService.info(ADD_STRATEGY_METHOD_NAME, {
|
|
3633
|
+
strategySchema,
|
|
3634
|
+
});
|
|
3635
|
+
backtest$1.strategyValidationService.addStrategy(strategySchema.strategyName, strategySchema);
|
|
3636
|
+
backtest$1.strategySchemaService.register(strategySchema.strategyName, strategySchema);
|
|
531
3637
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
3638
|
+
/**
|
|
3639
|
+
* Registers an exchange data source in the framework.
|
|
3640
|
+
*
|
|
3641
|
+
* The exchange provides:
|
|
3642
|
+
* - Historical candle data via getCandles
|
|
3643
|
+
* - Price/quantity formatting for the exchange
|
|
3644
|
+
* - VWAP calculation from last 5 1m candles
|
|
3645
|
+
*
|
|
3646
|
+
* @param exchangeSchema - Exchange configuration object
|
|
3647
|
+
* @param exchangeSchema.exchangeName - Unique exchange identifier
|
|
3648
|
+
* @param exchangeSchema.getCandles - Async function to fetch candle data
|
|
3649
|
+
* @param exchangeSchema.formatPrice - Async function to format prices
|
|
3650
|
+
* @param exchangeSchema.formatQuantity - Async function to format quantities
|
|
3651
|
+
* @param exchangeSchema.callbacks - Optional callback for candle data events
|
|
3652
|
+
*
|
|
3653
|
+
* @example
|
|
3654
|
+
* ```typescript
|
|
3655
|
+
* addExchange({
|
|
3656
|
+
* exchangeName: "binance",
|
|
3657
|
+
* getCandles: async (symbol, interval, since, limit) => {
|
|
3658
|
+
* // Fetch from Binance API or database
|
|
3659
|
+
* return [{
|
|
3660
|
+
* timestamp: Date.now(),
|
|
3661
|
+
* open: 50000,
|
|
3662
|
+
* high: 51000,
|
|
3663
|
+
* low: 49000,
|
|
3664
|
+
* close: 50500,
|
|
3665
|
+
* volume: 1000,
|
|
3666
|
+
* }];
|
|
3667
|
+
* },
|
|
3668
|
+
* formatPrice: async (symbol, price) => price.toFixed(2),
|
|
3669
|
+
* formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
|
|
3670
|
+
* });
|
|
3671
|
+
* ```
|
|
3672
|
+
*/
|
|
3673
|
+
function addExchange(exchangeSchema) {
|
|
3674
|
+
backtest$1.loggerService.info(ADD_EXCHANGE_METHOD_NAME, {
|
|
3675
|
+
exchangeSchema,
|
|
538
3676
|
});
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
3677
|
+
backtest$1.exchangeValidationService.addExchange(exchangeSchema.exchangeName, exchangeSchema);
|
|
3678
|
+
backtest$1.exchangeSchemaService.register(exchangeSchema.exchangeName, exchangeSchema);
|
|
3679
|
+
}
|
|
3680
|
+
/**
|
|
3681
|
+
* Registers a timeframe generator for backtesting.
|
|
3682
|
+
*
|
|
3683
|
+
* The frame defines:
|
|
3684
|
+
* - Start and end dates for backtest period
|
|
3685
|
+
* - Interval for timeframe generation
|
|
3686
|
+
* - Callback for timeframe generation events
|
|
3687
|
+
*
|
|
3688
|
+
* @param frameSchema - Frame configuration object
|
|
3689
|
+
* @param frameSchema.frameName - Unique frame identifier
|
|
3690
|
+
* @param frameSchema.interval - Timeframe interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h" | "12h" | "1d" | "3d")
|
|
3691
|
+
* @param frameSchema.startDate - Start date for timeframe generation
|
|
3692
|
+
* @param frameSchema.endDate - End date for timeframe generation
|
|
3693
|
+
* @param frameSchema.callbacks - Optional callback for timeframe events
|
|
3694
|
+
*
|
|
3695
|
+
* @example
|
|
3696
|
+
* ```typescript
|
|
3697
|
+
* addFrame({
|
|
3698
|
+
* frameName: "1d-backtest",
|
|
3699
|
+
* interval: "1m",
|
|
3700
|
+
* startDate: new Date("2024-01-01T00:00:00Z"),
|
|
3701
|
+
* endDate: new Date("2024-01-02T00:00:00Z"),
|
|
3702
|
+
* callbacks: {
|
|
3703
|
+
* onTimeframe: (timeframe, startDate, endDate, interval) => {
|
|
3704
|
+
* console.log(`Generated ${timeframe.length} timeframes`);
|
|
3705
|
+
* },
|
|
3706
|
+
* },
|
|
3707
|
+
* });
|
|
3708
|
+
* ```
|
|
3709
|
+
*/
|
|
3710
|
+
function addFrame(frameSchema) {
|
|
3711
|
+
backtest$1.loggerService.info(ADD_FRAME_METHOD_NAME, {
|
|
3712
|
+
frameSchema,
|
|
561
3713
|
});
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
table.push([]);
|
|
565
|
-
table.push([
|
|
566
|
-
"TOTAL",
|
|
567
|
-
`${closedCount} trades`,
|
|
568
|
-
`Win: ${winCount}`,
|
|
569
|
-
`Loss: ${lossCount}`,
|
|
570
|
-
"-",
|
|
571
|
-
`WR: ${closedCount > 0 ? ((winCount / closedCount) * 100).toFixed(1) : 0}%`,
|
|
572
|
-
`${totalPnl > 0 ? "+" : ""}${totalPnl.toFixed(2)}%`,
|
|
573
|
-
]);
|
|
574
|
-
console.log("\n");
|
|
575
|
-
console.log(table.toString());
|
|
576
|
-
console.log("\n");
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
async function reduce(symbol, timeframes, callback, initialValue) {
|
|
580
|
-
let accumulator = initialValue;
|
|
581
|
-
for (let i = 0; i < timeframes.length; i++) {
|
|
582
|
-
const when = timeframes[i];
|
|
583
|
-
accumulator = await callback(accumulator, i, when, symbol);
|
|
584
|
-
}
|
|
585
|
-
return {
|
|
586
|
-
symbol,
|
|
587
|
-
accumulator,
|
|
588
|
-
totalTicks: timeframes.length,
|
|
589
|
-
};
|
|
3714
|
+
backtest$1.frameValidationService.addFrame(frameSchema.frameName, frameSchema);
|
|
3715
|
+
backtest$1.frameSchemaService.register(frameSchema.frameName, frameSchema);
|
|
590
3716
|
}
|
|
591
3717
|
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
3718
|
+
const LISTEN_SIGNAL_METHOD_NAME = "event.listenSignal";
|
|
3719
|
+
const LISTEN_SIGNAL_ONCE_METHOD_NAME = "event.listenSignalOnce";
|
|
3720
|
+
const LISTEN_SIGNAL_LIVE_METHOD_NAME = "event.listenSignalLive";
|
|
3721
|
+
const LISTEN_SIGNAL_LIVE_ONCE_METHOD_NAME = "event.listenSignalLiveOnce";
|
|
3722
|
+
const LISTEN_SIGNAL_BACKTEST_METHOD_NAME = "event.listenSignalBacktest";
|
|
3723
|
+
const LISTEN_SIGNAL_BACKTEST_ONCE_METHOD_NAME = "event.listenSignalBacktestOnce";
|
|
3724
|
+
const LISTEN_ERROR_METHOD_NAME = "event.listenError";
|
|
3725
|
+
/**
|
|
3726
|
+
* Subscribes to all signal events with queued async processing.
|
|
3727
|
+
*
|
|
3728
|
+
* Events are processed sequentially in order received, even if callback is async.
|
|
3729
|
+
* Uses queued wrapper to prevent concurrent execution of the callback.
|
|
3730
|
+
*
|
|
3731
|
+
* @param fn - Callback function to handle signal events (idle, opened, active, closed)
|
|
3732
|
+
* @returns Unsubscribe function to stop listening
|
|
3733
|
+
*
|
|
3734
|
+
* @example
|
|
3735
|
+
* ```typescript
|
|
3736
|
+
* import { listenSignal } from "./function/event";
|
|
3737
|
+
*
|
|
3738
|
+
* const unsubscribe = listenSignal((event) => {
|
|
3739
|
+
* if (event.action === "opened") {
|
|
3740
|
+
* console.log("New signal opened:", event.signal);
|
|
3741
|
+
* } else if (event.action === "closed") {
|
|
3742
|
+
* console.log("Signal closed with PNL:", event.pnl.pnlPercentage);
|
|
3743
|
+
* }
|
|
3744
|
+
* });
|
|
3745
|
+
*
|
|
3746
|
+
* // Later: stop listening
|
|
3747
|
+
* unsubscribe();
|
|
3748
|
+
* ```
|
|
3749
|
+
*/
|
|
3750
|
+
function listenSignal(fn) {
|
|
3751
|
+
backtest$1.loggerService.log(LISTEN_SIGNAL_METHOD_NAME);
|
|
3752
|
+
return signalEmitter.subscribe(queued(async (event) => fn(event)));
|
|
616
3753
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
3754
|
+
/**
|
|
3755
|
+
* Subscribes to filtered signal events with one-time execution.
|
|
3756
|
+
*
|
|
3757
|
+
* Listens for events matching the filter predicate, then executes callback once
|
|
3758
|
+
* and automatically unsubscribes. Useful for waiting for specific signal conditions.
|
|
3759
|
+
*
|
|
3760
|
+
* @param filterFn - Predicate to filter which events trigger the callback
|
|
3761
|
+
* @param fn - Callback function to handle the filtered event (called only once)
|
|
3762
|
+
* @returns Unsubscribe function to cancel the listener before it fires
|
|
3763
|
+
*
|
|
3764
|
+
* @example
|
|
3765
|
+
* ```typescript
|
|
3766
|
+
* import { listenSignalOnce } from "./function/event";
|
|
3767
|
+
*
|
|
3768
|
+
* // Wait for first take profit hit
|
|
3769
|
+
* listenSignalOnce(
|
|
3770
|
+
* (event) => event.action === "closed" && event.closeReason === "take_profit",
|
|
3771
|
+
* (event) => {
|
|
3772
|
+
* console.log("Take profit hit! PNL:", event.pnl.pnlPercentage);
|
|
3773
|
+
* }
|
|
3774
|
+
* );
|
|
3775
|
+
*
|
|
3776
|
+
* // Wait for any signal to close on BTCUSDT
|
|
3777
|
+
* const cancel = listenSignalOnce(
|
|
3778
|
+
* (event) => event.action === "closed" && event.signal.symbol === "BTCUSDT",
|
|
3779
|
+
* (event) => console.log("BTCUSDT signal closed")
|
|
3780
|
+
* );
|
|
3781
|
+
*
|
|
3782
|
+
* // Cancel if needed before event fires
|
|
3783
|
+
* cancel();
|
|
3784
|
+
* ```
|
|
3785
|
+
*/
|
|
3786
|
+
function listenSignalOnce(filterFn, fn) {
|
|
3787
|
+
backtest$1.loggerService.log(LISTEN_SIGNAL_ONCE_METHOD_NAME);
|
|
3788
|
+
return signalEmitter.filter(filterFn).once(fn);
|
|
623
3789
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
3790
|
+
/**
|
|
3791
|
+
* Subscribes to live trading signal events with queued async processing.
|
|
3792
|
+
*
|
|
3793
|
+
* Only receives events from Live.run() execution.
|
|
3794
|
+
* Events are processed sequentially in order received.
|
|
3795
|
+
*
|
|
3796
|
+
* @param fn - Callback function to handle live signal events
|
|
3797
|
+
* @returns Unsubscribe function to stop listening
|
|
3798
|
+
*
|
|
3799
|
+
* @example
|
|
3800
|
+
* ```typescript
|
|
3801
|
+
* import { listenSignalLive } from "./function/event";
|
|
3802
|
+
*
|
|
3803
|
+
* const unsubscribe = listenSignalLive((event) => {
|
|
3804
|
+
* if (event.action === "closed") {
|
|
3805
|
+
* console.log("Live signal closed:", event.pnl.pnlPercentage);
|
|
3806
|
+
* }
|
|
3807
|
+
* });
|
|
3808
|
+
* ```
|
|
3809
|
+
*/
|
|
3810
|
+
function listenSignalLive(fn) {
|
|
3811
|
+
backtest$1.loggerService.log(LISTEN_SIGNAL_LIVE_METHOD_NAME);
|
|
3812
|
+
return signalLiveEmitter.subscribe(queued(async (event) => fn(event)));
|
|
3813
|
+
}
|
|
3814
|
+
/**
|
|
3815
|
+
* Subscribes to filtered live signal events with one-time execution.
|
|
3816
|
+
*
|
|
3817
|
+
* Only receives events from Live.run() execution.
|
|
3818
|
+
* Executes callback once and automatically unsubscribes.
|
|
3819
|
+
*
|
|
3820
|
+
* @param filterFn - Predicate to filter which events trigger the callback
|
|
3821
|
+
* @param fn - Callback function to handle the filtered event (called only once)
|
|
3822
|
+
* @returns Unsubscribe function to cancel the listener before it fires
|
|
3823
|
+
*
|
|
3824
|
+
* @example
|
|
3825
|
+
* ```typescript
|
|
3826
|
+
* import { listenSignalLiveOnce } from "./function/event";
|
|
3827
|
+
*
|
|
3828
|
+
* // Wait for first live take profit hit
|
|
3829
|
+
* listenSignalLiveOnce(
|
|
3830
|
+
* (event) => event.action === "closed" && event.closeReason === "take_profit",
|
|
3831
|
+
* (event) => console.log("Live take profit:", event.pnl.pnlPercentage)
|
|
3832
|
+
* );
|
|
3833
|
+
* ```
|
|
3834
|
+
*/
|
|
3835
|
+
function listenSignalLiveOnce(filterFn, fn) {
|
|
3836
|
+
backtest$1.loggerService.log(LISTEN_SIGNAL_LIVE_ONCE_METHOD_NAME);
|
|
3837
|
+
return signalLiveEmitter.filter(filterFn).once(fn);
|
|
3838
|
+
}
|
|
3839
|
+
/**
|
|
3840
|
+
* Subscribes to backtest signal events with queued async processing.
|
|
3841
|
+
*
|
|
3842
|
+
* Only receives events from Backtest.run() execution.
|
|
3843
|
+
* Events are processed sequentially in order received.
|
|
3844
|
+
*
|
|
3845
|
+
* @param fn - Callback function to handle backtest signal events
|
|
3846
|
+
* @returns Unsubscribe function to stop listening
|
|
3847
|
+
*
|
|
3848
|
+
* @example
|
|
3849
|
+
* ```typescript
|
|
3850
|
+
* import { listenSignalBacktest } from "./function/event";
|
|
3851
|
+
*
|
|
3852
|
+
* const unsubscribe = listenSignalBacktest((event) => {
|
|
3853
|
+
* if (event.action === "closed") {
|
|
3854
|
+
* console.log("Backtest signal closed:", event.pnl.pnlPercentage);
|
|
3855
|
+
* }
|
|
3856
|
+
* });
|
|
3857
|
+
* ```
|
|
3858
|
+
*/
|
|
3859
|
+
function listenSignalBacktest(fn) {
|
|
3860
|
+
backtest$1.loggerService.log(LISTEN_SIGNAL_BACKTEST_METHOD_NAME);
|
|
3861
|
+
return signalBacktestEmitter.subscribe(queued(async (event) => fn(event)));
|
|
3862
|
+
}
|
|
3863
|
+
/**
|
|
3864
|
+
* Subscribes to filtered backtest signal events with one-time execution.
|
|
3865
|
+
*
|
|
3866
|
+
* Only receives events from Backtest.run() execution.
|
|
3867
|
+
* Executes callback once and automatically unsubscribes.
|
|
3868
|
+
*
|
|
3869
|
+
* @param filterFn - Predicate to filter which events trigger the callback
|
|
3870
|
+
* @param fn - Callback function to handle the filtered event (called only once)
|
|
3871
|
+
* @returns Unsubscribe function to cancel the listener before it fires
|
|
3872
|
+
*
|
|
3873
|
+
* @example
|
|
3874
|
+
* ```typescript
|
|
3875
|
+
* import { listenSignalBacktestOnce } from "./function/event";
|
|
3876
|
+
*
|
|
3877
|
+
* // Wait for first backtest stop loss hit
|
|
3878
|
+
* listenSignalBacktestOnce(
|
|
3879
|
+
* (event) => event.action === "closed" && event.closeReason === "stop_loss",
|
|
3880
|
+
* (event) => console.log("Backtest stop loss:", event.pnl.pnlPercentage)
|
|
3881
|
+
* );
|
|
3882
|
+
* ```
|
|
3883
|
+
*/
|
|
3884
|
+
function listenSignalBacktestOnce(filterFn, fn) {
|
|
3885
|
+
backtest$1.loggerService.log(LISTEN_SIGNAL_BACKTEST_ONCE_METHOD_NAME);
|
|
3886
|
+
return signalBacktestEmitter.filter(filterFn).once(fn);
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3889
|
+
* Subscribes to background execution errors with queued async processing.
|
|
3890
|
+
*
|
|
3891
|
+
* Listens to errors caught in Live.background() and Backtest.background() execution.
|
|
3892
|
+
* Events are processed sequentially in order received, even if callback is async.
|
|
3893
|
+
* Uses queued wrapper to prevent concurrent execution of the callback.
|
|
3894
|
+
*
|
|
3895
|
+
* @param fn - Callback function to handle error events
|
|
3896
|
+
* @returns Unsubscribe function to stop listening
|
|
3897
|
+
*
|
|
3898
|
+
* @example
|
|
3899
|
+
* ```typescript
|
|
3900
|
+
* import { listenError } from "./function/event";
|
|
3901
|
+
*
|
|
3902
|
+
* const unsubscribe = listenError((error) => {
|
|
3903
|
+
* console.error("Background execution error:", error.message);
|
|
3904
|
+
* // Log to monitoring service, send alerts, etc.
|
|
3905
|
+
* });
|
|
3906
|
+
*
|
|
3907
|
+
* // Later: stop listening
|
|
3908
|
+
* unsubscribe();
|
|
3909
|
+
* ```
|
|
3910
|
+
*/
|
|
3911
|
+
function listenError(fn) {
|
|
3912
|
+
backtest$1.loggerService.log(LISTEN_ERROR_METHOD_NAME);
|
|
3913
|
+
return errorEmitter.subscribe(queued(async (error) => fn(error)));
|
|
629
3914
|
}
|
|
630
3915
|
|
|
3916
|
+
const GET_CANDLES_METHOD_NAME = "exchange.getCandles";
|
|
3917
|
+
const GET_AVERAGE_PRICE_METHOD_NAME = "exchange.getAveragePrice";
|
|
3918
|
+
const FORMAT_PRICE_METHOD_NAME = "exchange.formatPrice";
|
|
3919
|
+
const FORMAT_QUANTITY_METHOD_NAME = "exchange.formatQuantity";
|
|
3920
|
+
const GET_DATE_METHOD_NAME = "exchange.getDate";
|
|
3921
|
+
const GET_MODE_METHOD_NAME = "exchange.getMode";
|
|
3922
|
+
/**
|
|
3923
|
+
* Fetches historical candle data from the registered exchange.
|
|
3924
|
+
*
|
|
3925
|
+
* Candles are fetched backwards from the current execution context time.
|
|
3926
|
+
* Uses the exchange's getCandles implementation.
|
|
3927
|
+
*
|
|
3928
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
3929
|
+
* @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
|
|
3930
|
+
* @param limit - Number of candles to fetch
|
|
3931
|
+
* @returns Promise resolving to array of candle data
|
|
3932
|
+
*
|
|
3933
|
+
* @example
|
|
3934
|
+
* ```typescript
|
|
3935
|
+
* const candles = await getCandles("BTCUSDT", "1m", 100);
|
|
3936
|
+
* console.log(candles[0]); // { timestamp, open, high, low, close, volume }
|
|
3937
|
+
* ```
|
|
3938
|
+
*/
|
|
631
3939
|
async function getCandles(symbol, interval, limit) {
|
|
632
|
-
|
|
3940
|
+
backtest$1.loggerService.info(GET_CANDLES_METHOD_NAME, {
|
|
3941
|
+
symbol,
|
|
3942
|
+
interval,
|
|
3943
|
+
limit,
|
|
3944
|
+
});
|
|
3945
|
+
return await backtest$1.exchangeConnectionService.getCandles(symbol, interval, limit);
|
|
633
3946
|
}
|
|
3947
|
+
/**
|
|
3948
|
+
* Calculates VWAP (Volume Weighted Average Price) for a symbol.
|
|
3949
|
+
*
|
|
3950
|
+
* Uses the last 5 1-minute candles to calculate:
|
|
3951
|
+
* - Typical Price = (high + low + close) / 3
|
|
3952
|
+
* - VWAP = sum(typical_price * volume) / sum(volume)
|
|
3953
|
+
*
|
|
3954
|
+
* If volume is zero, returns simple average of close prices.
|
|
3955
|
+
*
|
|
3956
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
3957
|
+
* @returns Promise resolving to VWAP price
|
|
3958
|
+
*
|
|
3959
|
+
* @example
|
|
3960
|
+
* ```typescript
|
|
3961
|
+
* const vwap = await getAveragePrice("BTCUSDT");
|
|
3962
|
+
* console.log(vwap); // 50125.43
|
|
3963
|
+
* ```
|
|
3964
|
+
*/
|
|
634
3965
|
async function getAveragePrice(symbol) {
|
|
635
|
-
|
|
3966
|
+
backtest$1.loggerService.info(GET_AVERAGE_PRICE_METHOD_NAME, {
|
|
3967
|
+
symbol,
|
|
3968
|
+
});
|
|
3969
|
+
return await backtest$1.exchangeConnectionService.getAveragePrice(symbol);
|
|
636
3970
|
}
|
|
3971
|
+
/**
|
|
3972
|
+
* Formats a price value according to exchange rules.
|
|
3973
|
+
*
|
|
3974
|
+
* Uses the exchange's formatPrice implementation for proper decimal places.
|
|
3975
|
+
*
|
|
3976
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
3977
|
+
* @param price - Raw price value
|
|
3978
|
+
* @returns Promise resolving to formatted price string
|
|
3979
|
+
*
|
|
3980
|
+
* @example
|
|
3981
|
+
* ```typescript
|
|
3982
|
+
* const formatted = await formatPrice("BTCUSDT", 50000.123456);
|
|
3983
|
+
* console.log(formatted); // "50000.12"
|
|
3984
|
+
* ```
|
|
3985
|
+
*/
|
|
637
3986
|
async function formatPrice(symbol, price) {
|
|
638
|
-
|
|
3987
|
+
backtest$1.loggerService.info(FORMAT_PRICE_METHOD_NAME, {
|
|
3988
|
+
symbol,
|
|
3989
|
+
price,
|
|
3990
|
+
});
|
|
3991
|
+
return await backtest$1.exchangeConnectionService.formatPrice(symbol, price);
|
|
639
3992
|
}
|
|
3993
|
+
/**
|
|
3994
|
+
* Formats a quantity value according to exchange rules.
|
|
3995
|
+
*
|
|
3996
|
+
* Uses the exchange's formatQuantity implementation for proper decimal places.
|
|
3997
|
+
*
|
|
3998
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
3999
|
+
* @param quantity - Raw quantity value
|
|
4000
|
+
* @returns Promise resolving to formatted quantity string
|
|
4001
|
+
*
|
|
4002
|
+
* @example
|
|
4003
|
+
* ```typescript
|
|
4004
|
+
* const formatted = await formatQuantity("BTCUSDT", 0.123456789);
|
|
4005
|
+
* console.log(formatted); // "0.12345678"
|
|
4006
|
+
* ```
|
|
4007
|
+
*/
|
|
640
4008
|
async function formatQuantity(symbol, quantity) {
|
|
641
|
-
|
|
4009
|
+
backtest$1.loggerService.info(FORMAT_QUANTITY_METHOD_NAME, {
|
|
4010
|
+
symbol,
|
|
4011
|
+
quantity,
|
|
4012
|
+
});
|
|
4013
|
+
return await backtest$1.exchangeConnectionService.formatQuantity(symbol, quantity);
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* Gets the current date from execution context.
|
|
4017
|
+
*
|
|
4018
|
+
* In backtest mode: returns the current timeframe date being processed
|
|
4019
|
+
* In live mode: returns current real-time date
|
|
4020
|
+
*
|
|
4021
|
+
* @returns Promise resolving to current execution context date
|
|
4022
|
+
*
|
|
4023
|
+
* @example
|
|
4024
|
+
* ```typescript
|
|
4025
|
+
* const date = await getDate();
|
|
4026
|
+
* console.log(date); // 2024-01-01T12:00:00.000Z
|
|
4027
|
+
* ```
|
|
4028
|
+
*/
|
|
4029
|
+
async function getDate() {
|
|
4030
|
+
backtest$1.loggerService.info(GET_DATE_METHOD_NAME);
|
|
4031
|
+
const { when } = backtest$1.executionContextService.context;
|
|
4032
|
+
return new Date(when.getTime());
|
|
4033
|
+
}
|
|
4034
|
+
/**
|
|
4035
|
+
* Gets the current execution mode.
|
|
4036
|
+
*
|
|
4037
|
+
* @returns Promise resolving to "backtest" or "live"
|
|
4038
|
+
*
|
|
4039
|
+
* @example
|
|
4040
|
+
* ```typescript
|
|
4041
|
+
* const mode = await getMode();
|
|
4042
|
+
* if (mode === "backtest") {
|
|
4043
|
+
* console.log("Running in backtest mode");
|
|
4044
|
+
* } else {
|
|
4045
|
+
* console.log("Running in live mode");
|
|
4046
|
+
* }
|
|
4047
|
+
* ```
|
|
4048
|
+
*/
|
|
4049
|
+
async function getMode() {
|
|
4050
|
+
backtest$1.loggerService.info(GET_MODE_METHOD_NAME);
|
|
4051
|
+
const { backtest: bt } = backtest$1.executionContextService.context;
|
|
4052
|
+
return bt ? "backtest" : "live";
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
const BACKTEST_METHOD_NAME_RUN = "BacktestUtils.run";
|
|
4056
|
+
const BACKTEST_METHOD_NAME_BACKGROUND = "BacktestUtils.background";
|
|
4057
|
+
const BACKTEST_METHOD_NAME_GET_REPORT = "BacktestUtils.getReport";
|
|
4058
|
+
const BACKTEST_METHOD_NAME_DUMP = "BacktestUtils.dump";
|
|
4059
|
+
/**
|
|
4060
|
+
* Utility class for backtest operations.
|
|
4061
|
+
*
|
|
4062
|
+
* Provides simplified access to backtestGlobalService.run() with logging.
|
|
4063
|
+
* Exported as singleton instance for convenient usage.
|
|
4064
|
+
*
|
|
4065
|
+
* @example
|
|
4066
|
+
* ```typescript
|
|
4067
|
+
* import { Backtest } from "./classes/Backtest";
|
|
4068
|
+
*
|
|
4069
|
+
* for await (const result of Backtest.run("BTCUSDT", {
|
|
4070
|
+
* strategyName: "my-strategy",
|
|
4071
|
+
* exchangeName: "my-exchange",
|
|
4072
|
+
* frameName: "1d-backtest"
|
|
4073
|
+
* })) {
|
|
4074
|
+
* console.log("Closed signal PNL:", result.pnl.pnlPercentage);
|
|
4075
|
+
* }
|
|
4076
|
+
* ```
|
|
4077
|
+
*/
|
|
4078
|
+
class BacktestUtils {
|
|
4079
|
+
constructor() {
|
|
4080
|
+
/**
|
|
4081
|
+
* Runs backtest for a symbol with context propagation.
|
|
4082
|
+
*
|
|
4083
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
4084
|
+
* @param context - Execution context with strategy, exchange, and frame names
|
|
4085
|
+
* @returns Async generator yielding closed signals with PNL
|
|
4086
|
+
*/
|
|
4087
|
+
this.run = (symbol, context) => {
|
|
4088
|
+
backtest$1.loggerService.info(BACKTEST_METHOD_NAME_RUN, {
|
|
4089
|
+
symbol,
|
|
4090
|
+
context,
|
|
4091
|
+
});
|
|
4092
|
+
return backtest$1.backtestGlobalService.run(symbol, context);
|
|
4093
|
+
};
|
|
4094
|
+
/**
|
|
4095
|
+
* Runs backtest in background without yielding results.
|
|
4096
|
+
*
|
|
4097
|
+
* Consumes all backtest results internally without exposing them.
|
|
4098
|
+
* Useful for running backtests for side effects only (callbacks, logging).
|
|
4099
|
+
*
|
|
4100
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
4101
|
+
* @param context - Execution context with strategy, exchange, and frame names
|
|
4102
|
+
* @returns Cancellation closure
|
|
4103
|
+
*
|
|
4104
|
+
* @example
|
|
4105
|
+
* ```typescript
|
|
4106
|
+
* // Run backtest silently, only callbacks will fire
|
|
4107
|
+
* await Backtest.background("BTCUSDT", {
|
|
4108
|
+
* strategyName: "my-strategy",
|
|
4109
|
+
* exchangeName: "my-exchange",
|
|
4110
|
+
* frameName: "1d-backtest"
|
|
4111
|
+
* });
|
|
4112
|
+
* console.log("Backtest completed");
|
|
4113
|
+
* ```
|
|
4114
|
+
*/
|
|
4115
|
+
this.background = async (symbol, context) => {
|
|
4116
|
+
backtest$1.loggerService.info(BACKTEST_METHOD_NAME_BACKGROUND, {
|
|
4117
|
+
symbol,
|
|
4118
|
+
context,
|
|
4119
|
+
});
|
|
4120
|
+
const iterator = this.run(symbol, context);
|
|
4121
|
+
let isStopped = false;
|
|
4122
|
+
const task = async () => {
|
|
4123
|
+
while (true) {
|
|
4124
|
+
const { done } = await iterator.next();
|
|
4125
|
+
if (done) {
|
|
4126
|
+
break;
|
|
4127
|
+
}
|
|
4128
|
+
if (isStopped) {
|
|
4129
|
+
break;
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
};
|
|
4133
|
+
task().catch((error) => errorEmitter.next(new Error(getErrorMessage(error))));
|
|
4134
|
+
return () => {
|
|
4135
|
+
isStopped = true;
|
|
4136
|
+
};
|
|
4137
|
+
};
|
|
4138
|
+
/**
|
|
4139
|
+
* Generates markdown report with all closed signals for a strategy.
|
|
4140
|
+
*
|
|
4141
|
+
* @param strategyName - Strategy name to generate report for
|
|
4142
|
+
* @returns Promise resolving to markdown formatted report string
|
|
4143
|
+
*
|
|
4144
|
+
* @example
|
|
4145
|
+
* ```typescript
|
|
4146
|
+
* const markdown = await Backtest.getReport("my-strategy");
|
|
4147
|
+
* console.log(markdown);
|
|
4148
|
+
* ```
|
|
4149
|
+
*/
|
|
4150
|
+
this.getReport = async (strategyName) => {
|
|
4151
|
+
backtest$1.loggerService.info(BACKTEST_METHOD_NAME_GET_REPORT, {
|
|
4152
|
+
strategyName,
|
|
4153
|
+
});
|
|
4154
|
+
return await backtest$1.backtestMarkdownService.getReport(strategyName);
|
|
4155
|
+
};
|
|
4156
|
+
/**
|
|
4157
|
+
* Saves strategy report to disk.
|
|
4158
|
+
*
|
|
4159
|
+
* @param strategyName - Strategy name to save report for
|
|
4160
|
+
* @param path - Optional directory path to save report (default: "./logs/backtest")
|
|
4161
|
+
*
|
|
4162
|
+
* @example
|
|
4163
|
+
* ```typescript
|
|
4164
|
+
* // Save to default path: ./logs/backtest/my-strategy.md
|
|
4165
|
+
* await Backtest.dump("my-strategy");
|
|
4166
|
+
*
|
|
4167
|
+
* // Save to custom path: ./custom/path/my-strategy.md
|
|
4168
|
+
* await Backtest.dump("my-strategy", "./custom/path");
|
|
4169
|
+
* ```
|
|
4170
|
+
*/
|
|
4171
|
+
this.dump = async (strategyName, path) => {
|
|
4172
|
+
backtest$1.loggerService.info(BACKTEST_METHOD_NAME_DUMP, {
|
|
4173
|
+
strategyName,
|
|
4174
|
+
path,
|
|
4175
|
+
});
|
|
4176
|
+
await backtest$1.backtestMarkdownService.dump(strategyName, path);
|
|
4177
|
+
};
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
/**
|
|
4181
|
+
* Singleton instance of BacktestUtils for convenient backtest operations.
|
|
4182
|
+
*
|
|
4183
|
+
* @example
|
|
4184
|
+
* ```typescript
|
|
4185
|
+
* import { Backtest } from "./classes/Backtest";
|
|
4186
|
+
*
|
|
4187
|
+
* for await (const result of Backtest.run("BTCUSDT", {
|
|
4188
|
+
* strategyName: "my-strategy",
|
|
4189
|
+
* exchangeName: "my-exchange",
|
|
4190
|
+
* frameName: "1d-backtest"
|
|
4191
|
+
* })) {
|
|
4192
|
+
* if (result.action === "closed") {
|
|
4193
|
+
* console.log("PNL:", result.pnl.pnlPercentage);
|
|
4194
|
+
* }
|
|
4195
|
+
* }
|
|
4196
|
+
* ```
|
|
4197
|
+
*/
|
|
4198
|
+
const Backtest = new BacktestUtils();
|
|
4199
|
+
|
|
4200
|
+
const LIVE_METHOD_NAME_RUN = "LiveUtils.run";
|
|
4201
|
+
const LIVE_METHOD_NAME_BACKGROUND = "LiveUtils.background";
|
|
4202
|
+
const LIVE_METHOD_NAME_GET_REPORT = "LiveUtils.getReport";
|
|
4203
|
+
const LIVE_METHOD_NAME_DUMP = "LiveUtils.dump";
|
|
4204
|
+
/**
|
|
4205
|
+
* Utility class for live trading operations.
|
|
4206
|
+
*
|
|
4207
|
+
* Provides simplified access to liveGlobalService.run() with logging.
|
|
4208
|
+
* Exported as singleton instance for convenient usage.
|
|
4209
|
+
*
|
|
4210
|
+
* Features:
|
|
4211
|
+
* - Infinite async generator (never completes)
|
|
4212
|
+
* - Crash recovery via persisted state
|
|
4213
|
+
* - Real-time progression with Date.now()
|
|
4214
|
+
*
|
|
4215
|
+
* @example
|
|
4216
|
+
* ```typescript
|
|
4217
|
+
* import { Live } from "./classes/Live";
|
|
4218
|
+
*
|
|
4219
|
+
* // Infinite loop - use Ctrl+C to stop
|
|
4220
|
+
* for await (const result of Live.run("BTCUSDT", {
|
|
4221
|
+
* strategyName: "my-strategy",
|
|
4222
|
+
* exchangeName: "my-exchange",
|
|
4223
|
+
* frameName: ""
|
|
4224
|
+
* })) {
|
|
4225
|
+
* if (result.action === "opened") {
|
|
4226
|
+
* console.log("Signal opened:", result.signal);
|
|
4227
|
+
* } else if (result.action === "closed") {
|
|
4228
|
+
* console.log("PNL:", result.pnl.pnlPercentage);
|
|
4229
|
+
* }
|
|
4230
|
+
* }
|
|
4231
|
+
* ```
|
|
4232
|
+
*/
|
|
4233
|
+
class LiveUtils {
|
|
4234
|
+
constructor() {
|
|
4235
|
+
/**
|
|
4236
|
+
* Runs live trading for a symbol with context propagation.
|
|
4237
|
+
*
|
|
4238
|
+
* Infinite async generator with crash recovery support.
|
|
4239
|
+
* Process can crash and restart - state will be recovered from disk.
|
|
4240
|
+
*
|
|
4241
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
4242
|
+
* @param context - Execution context with strategy and exchange names
|
|
4243
|
+
* @returns Infinite async generator yielding opened and closed signals
|
|
4244
|
+
*/
|
|
4245
|
+
this.run = (symbol, context) => {
|
|
4246
|
+
backtest$1.loggerService.info(LIVE_METHOD_NAME_RUN, {
|
|
4247
|
+
symbol,
|
|
4248
|
+
context,
|
|
4249
|
+
});
|
|
4250
|
+
return backtest$1.liveGlobalService.run(symbol, context);
|
|
4251
|
+
};
|
|
4252
|
+
/**
|
|
4253
|
+
* Runs live trading in background without yielding results.
|
|
4254
|
+
*
|
|
4255
|
+
* Consumes all live trading results internally without exposing them.
|
|
4256
|
+
* Infinite loop - will run until process is stopped or crashes.
|
|
4257
|
+
* Useful for running live trading for side effects only (callbacks, persistence).
|
|
4258
|
+
*
|
|
4259
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
4260
|
+
* @param context - Execution context with strategy and exchange names
|
|
4261
|
+
* @returns Cancellation closure
|
|
4262
|
+
*
|
|
4263
|
+
* @example
|
|
4264
|
+
* ```typescript
|
|
4265
|
+
* // Run live trading silently in background, only callbacks will fire
|
|
4266
|
+
* // This will run forever until Ctrl+C
|
|
4267
|
+
* await Live.background("BTCUSDT", {
|
|
4268
|
+
* strategyName: "my-strategy",
|
|
4269
|
+
* exchangeName: "my-exchange"
|
|
4270
|
+
* });
|
|
4271
|
+
* ```
|
|
4272
|
+
*/
|
|
4273
|
+
this.background = async (symbol, context) => {
|
|
4274
|
+
backtest$1.loggerService.info(LIVE_METHOD_NAME_BACKGROUND, {
|
|
4275
|
+
symbol,
|
|
4276
|
+
context,
|
|
4277
|
+
});
|
|
4278
|
+
const iterator = this.run(symbol, context);
|
|
4279
|
+
let isStopped = false;
|
|
4280
|
+
let lastValue = null;
|
|
4281
|
+
const task = async () => {
|
|
4282
|
+
while (true) {
|
|
4283
|
+
const { value, done } = await iterator.next();
|
|
4284
|
+
if (value) {
|
|
4285
|
+
lastValue = value;
|
|
4286
|
+
}
|
|
4287
|
+
if (done) {
|
|
4288
|
+
break;
|
|
4289
|
+
}
|
|
4290
|
+
if (lastValue?.action === "closed" && isStopped) {
|
|
4291
|
+
break;
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
};
|
|
4295
|
+
task().catch((error) => errorEmitter.next(new Error(getErrorMessage(error))));
|
|
4296
|
+
return () => {
|
|
4297
|
+
isStopped = true;
|
|
4298
|
+
};
|
|
4299
|
+
};
|
|
4300
|
+
/**
|
|
4301
|
+
* Generates markdown report with all events for a strategy.
|
|
4302
|
+
*
|
|
4303
|
+
* @param strategyName - Strategy name to generate report for
|
|
4304
|
+
* @returns Promise resolving to markdown formatted report string
|
|
4305
|
+
*
|
|
4306
|
+
* @example
|
|
4307
|
+
* ```typescript
|
|
4308
|
+
* const markdown = await Live.getReport("my-strategy");
|
|
4309
|
+
* console.log(markdown);
|
|
4310
|
+
* ```
|
|
4311
|
+
*/
|
|
4312
|
+
this.getReport = async (strategyName) => {
|
|
4313
|
+
backtest$1.loggerService.info(LIVE_METHOD_NAME_GET_REPORT, {
|
|
4314
|
+
strategyName,
|
|
4315
|
+
});
|
|
4316
|
+
return await backtest$1.liveMarkdownService.getReport(strategyName);
|
|
4317
|
+
};
|
|
4318
|
+
/**
|
|
4319
|
+
* Saves strategy report to disk.
|
|
4320
|
+
*
|
|
4321
|
+
* @param strategyName - Strategy name to save report for
|
|
4322
|
+
* @param path - Optional directory path to save report (default: "./logs/live")
|
|
4323
|
+
*
|
|
4324
|
+
* @example
|
|
4325
|
+
* ```typescript
|
|
4326
|
+
* // Save to default path: ./logs/live/my-strategy.md
|
|
4327
|
+
* await Live.dump("my-strategy");
|
|
4328
|
+
*
|
|
4329
|
+
* // Save to custom path: ./custom/path/my-strategy.md
|
|
4330
|
+
* await Live.dump("my-strategy", "./custom/path");
|
|
4331
|
+
* ```
|
|
4332
|
+
*/
|
|
4333
|
+
this.dump = async (strategyName, path) => {
|
|
4334
|
+
backtest$1.loggerService.info(LIVE_METHOD_NAME_DUMP, {
|
|
4335
|
+
strategyName,
|
|
4336
|
+
path,
|
|
4337
|
+
});
|
|
4338
|
+
await backtest$1.liveMarkdownService.dump(strategyName, path);
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
642
4341
|
}
|
|
4342
|
+
/**
|
|
4343
|
+
* Singleton instance of LiveUtils for convenient live trading operations.
|
|
4344
|
+
*
|
|
4345
|
+
* @example
|
|
4346
|
+
* ```typescript
|
|
4347
|
+
* import { Live } from "./classes/Live";
|
|
4348
|
+
*
|
|
4349
|
+
* for await (const result of Live.run("BTCUSDT", {
|
|
4350
|
+
* strategyName: "my-strategy",
|
|
4351
|
+
* exchangeName: "my-exchange",
|
|
4352
|
+
* })) {
|
|
4353
|
+
* console.log("Result:", result.action);
|
|
4354
|
+
* }
|
|
4355
|
+
* ```
|
|
4356
|
+
*/
|
|
4357
|
+
const Live = new LiveUtils();
|
|
643
4358
|
|
|
644
|
-
export { ExecutionContextService, addExchange,
|
|
4359
|
+
export { Backtest, ExecutionContextService, Live, MethodContextService, PersistBase, PersistSignalAdaper, addExchange, addFrame, addStrategy, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listenError, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, setLogger };
|