backtest-kit 1.1.9 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.mjs CHANGED
@@ -1,11 +1,80 @@
1
1
  import { createActivator } from 'di-kit';
2
2
  import { scoped } from 'di-scoped';
3
- import { memoize, makeExtendable, singleshot, getErrorMessage, not, trycatch, retry, Subject, randomString, errorData, ToolRegistry, isObject, sleep, resolveDocuments, str, queued } from 'functools-kit';
3
+ import { errorData, getErrorMessage, sleep, memoize, makeExtendable, singleshot, not, trycatch, retry, Subject, randomString, ToolRegistry, isObject, resolveDocuments, str, queued } from 'functools-kit';
4
4
  import fs, { mkdir, writeFile } from 'fs/promises';
5
5
  import path, { join } from 'path';
6
6
  import crypto from 'crypto';
7
7
  import os from 'os';
8
8
 
9
+ const GLOBAL_CONFIG = {
10
+ /**
11
+ * Time to wait for scheduled signal to activate (in minutes)
12
+ * If signal does not activate within this time, it will be cancelled.
13
+ */
14
+ CC_SCHEDULE_AWAIT_MINUTES: 120,
15
+ /**
16
+ * Number of candles to use for average price calculation (VWAP)
17
+ * Default: 5 candles (last 5 minutes when using 1m interval)
18
+ */
19
+ CC_AVG_PRICE_CANDLES_COUNT: 5,
20
+ /**
21
+ * Minimum TakeProfit distance from priceOpen (percentage)
22
+ * Must be greater than trading fees to ensure profitable trades
23
+ * Default: 0.3% (covers 2×0.1% fees + minimum profit margin)
24
+ */
25
+ CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.1,
26
+ /**
27
+ * Maximum StopLoss distance from priceOpen (percentage)
28
+ * Prevents catastrophic losses from extreme StopLoss values
29
+ * Default: 20% (one signal cannot lose more than 20% of position)
30
+ */
31
+ CC_MAX_STOPLOSS_DISTANCE_PERCENT: 20,
32
+ /**
33
+ * Maximum signal lifetime in minutes
34
+ * Prevents eternal signals that block risk limits for weeks/months
35
+ * Default: 1440 minutes (1 day)
36
+ */
37
+ CC_MAX_SIGNAL_LIFETIME_MINUTES: 1440,
38
+ /**
39
+ * Number of retries for getCandles function
40
+ * Default: 3 retries
41
+ */
42
+ CC_GET_CANDLES_RETRY_COUNT: 3,
43
+ /**
44
+ * Delay between retries for getCandles function (in milliseconds)
45
+ * Default: 5000 ms (5 seconds)
46
+ */
47
+ CC_GET_CANDLES_RETRY_DELAY_MS: 5000,
48
+ /**
49
+ * Maximum allowed deviation factor for price anomaly detection.
50
+ * Price should not be more than this factor lower than reference price.
51
+ *
52
+ * Reasoning:
53
+ * - Incomplete candles from Binance API typically have prices near 0 (e.g., $0.01-1)
54
+ * - Normal BTC price ranges: $20,000-100,000
55
+ * - Factor 1000 catches prices below $20-100 when median is $20,000-100,000
56
+ * - Factor 100 would be too permissive (allows $200 when median is $20,000)
57
+ * - Factor 10000 might be too strict for low-cap altcoins
58
+ *
59
+ * Example: BTC at $50,000 median → threshold $50 (catches $0.01-1 anomalies)
60
+ */
61
+ CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR: 1000,
62
+ /**
63
+ * Minimum number of candles required for reliable median calculation.
64
+ * Below this threshold, use simple average instead of median.
65
+ *
66
+ * Reasoning:
67
+ * - Each candle provides 4 price points (OHLC)
68
+ * - 5 candles = 20 price points, sufficient for robust median calculation
69
+ * - Below 5 candles, single anomaly can heavily skew median
70
+ * - Statistical rule of thumb: minimum 7-10 data points for median stability
71
+ * - Average is more stable than median for small datasets (n < 20)
72
+ *
73
+ * Example: 3 candles = 12 points (use average), 5 candles = 20 points (use median)
74
+ */
75
+ CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN: 5,
76
+ };
77
+
9
78
  const { init, inject, provide } = createActivator("backtest");
10
79
 
11
80
  /**
@@ -79,6 +148,7 @@ const logicPublicServices$1 = {
79
148
  const markdownServices$1 = {
80
149
  backtestMarkdownService: Symbol('backtestMarkdownService'),
81
150
  liveMarkdownService: Symbol('liveMarkdownService'),
151
+ scheduleMarkdownService: Symbol('scheduleMarkdownService'),
82
152
  performanceMarkdownService: Symbol('performanceMarkdownService'),
83
153
  walkerMarkdownService: Symbol('walkerMarkdownService'),
84
154
  heatMarkdownService: Symbol('heatMarkdownService'),
@@ -237,6 +307,92 @@ const INTERVAL_MINUTES$2 = {
237
307
  "6h": 360,
238
308
  "8h": 480,
239
309
  };
310
+ /**
311
+ * Validates that all candles have valid OHLCV data without anomalies.
312
+ * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
313
+ * Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
314
+ *
315
+ * @param candles - Array of candle data to validate
316
+ * @throws Error if any candles have anomalous OHLCV values
317
+ */
318
+ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
319
+ if (candles.length === 0) {
320
+ return;
321
+ }
322
+ // Calculate reference price (median or average depending on candle count)
323
+ const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
324
+ const validPrices = allPrices.filter(p => p > 0);
325
+ let referencePrice;
326
+ if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
327
+ // Use median for reliable statistics with enough data
328
+ const sortedPrices = [...validPrices].sort((a, b) => a - b);
329
+ referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
330
+ }
331
+ else {
332
+ // Use average for small datasets (more stable than median)
333
+ const sum = validPrices.reduce((acc, p) => acc + p, 0);
334
+ referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
335
+ }
336
+ if (referencePrice === 0) {
337
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
338
+ }
339
+ const minValidPrice = referencePrice / GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
340
+ for (let i = 0; i < candles.length; i++) {
341
+ const candle = candles[i];
342
+ // Check for invalid numeric values
343
+ if (!Number.isFinite(candle.open) ||
344
+ !Number.isFinite(candle.high) ||
345
+ !Number.isFinite(candle.low) ||
346
+ !Number.isFinite(candle.close) ||
347
+ !Number.isFinite(candle.volume) ||
348
+ !Number.isFinite(candle.timestamp)) {
349
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
350
+ }
351
+ // Check for negative values
352
+ if (candle.open <= 0 ||
353
+ candle.high <= 0 ||
354
+ candle.low <= 0 ||
355
+ candle.close <= 0 ||
356
+ candle.volume < 0) {
357
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
358
+ }
359
+ // Check for anomalously low prices (incomplete candle indicator)
360
+ if (candle.open < minValidPrice ||
361
+ candle.high < minValidPrice ||
362
+ candle.low < minValidPrice ||
363
+ candle.close < minValidPrice) {
364
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
365
+ `OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
366
+ `reference: ${referencePrice}, threshold: ${minValidPrice}`);
367
+ }
368
+ }
369
+ };
370
+ /**
371
+ * Retries the getCandles function with specified retry count and delay.
372
+ * @param dto - Data transfer object containing symbol, interval, and limit
373
+ * @param since - Date object representing the start time for fetching candles
374
+ * @param self - Instance of ClientExchange
375
+ * @returns Promise resolving to array of candle data
376
+ */
377
+ const GET_CANDLES_FN = async (dto, since, self) => {
378
+ let lastError;
379
+ for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
380
+ try {
381
+ const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit);
382
+ VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
383
+ return result;
384
+ }
385
+ catch (err) {
386
+ self.params.logger.warn(`ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`, {
387
+ error: errorData(err),
388
+ message: getErrorMessage(err),
389
+ });
390
+ lastError = err;
391
+ await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
392
+ }
393
+ }
394
+ throw lastError;
395
+ };
240
396
  /**
241
397
  * Client implementation for exchange data access.
242
398
  *
@@ -287,7 +443,7 @@ class ClientExchange {
287
443
  throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
288
444
  }
289
445
  const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
290
- const data = await this.params.getCandles(symbol, interval, since, limit);
446
+ const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
291
447
  // Filter candles to strictly match the requested range
292
448
  const whenTimestamp = this.params.execution.context.when.getTime();
293
449
  const sinceTimestamp = since.getTime();
@@ -325,7 +481,7 @@ class ClientExchange {
325
481
  if (endTime > now) {
326
482
  return [];
327
483
  }
328
- const data = await this.params.getCandles(symbol, interval, since, limit);
484
+ const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
329
485
  // Filter candles to strictly match the requested range
330
486
  const sinceTimestamp = since.getTime();
331
487
  const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= endTime);
@@ -338,7 +494,8 @@ class ClientExchange {
338
494
  return filteredData;
339
495
  }
340
496
  /**
341
- * Calculates VWAP (Volume Weighted Average Price) from last 5 1m candles.
497
+ * Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
498
+ * The number of candles is configurable via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT.
342
499
  *
343
500
  * Formula:
344
501
  * - Typical Price = (high + low + close) / 3
@@ -354,7 +511,7 @@ class ClientExchange {
354
511
  this.params.logger.debug(`ClientExchange getAveragePrice`, {
355
512
  symbol,
356
513
  });
357
- const candles = await this.getCandles(symbol, "1m", 5);
514
+ const candles = await this.getCandles(symbol, "1m", GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
358
515
  if (candles.length === 0) {
359
516
  throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`);
360
517
  }
@@ -1091,7 +1248,7 @@ class PersistSignalUtils {
1091
1248
  * async readValue(id) { return JSON.parse(await redis.get(id)); }
1092
1249
  * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1093
1250
  * }
1094
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
1251
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1095
1252
  * ```
1096
1253
  */
1097
1254
  usePersistSignalAdapter(Ctor) {
@@ -1106,16 +1263,16 @@ class PersistSignalUtils {
1106
1263
  * @example
1107
1264
  * ```typescript
1108
1265
  * // Custom adapter
1109
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
1266
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1110
1267
  *
1111
1268
  * // Read signal
1112
- * const signal = await PersistSignalAdaper.readSignalData("my-strategy", "BTCUSDT");
1269
+ * const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
1113
1270
  *
1114
1271
  * // Write signal
1115
- * await PersistSignalAdaper.writeSignalData(signal, "my-strategy", "BTCUSDT");
1272
+ * await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
1116
1273
  * ```
1117
1274
  */
1118
- const PersistSignalAdaper = new PersistSignalUtils();
1275
+ const PersistSignalAdapter = new PersistSignalUtils();
1119
1276
  const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
1120
1277
  const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
1121
1278
  const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
@@ -1300,14 +1457,24 @@ const INTERVAL_MINUTES$1 = {
1300
1457
  };
1301
1458
  const VALIDATE_SIGNAL_FN = (signal) => {
1302
1459
  const errors = [];
1303
- // Валидация цен
1304
- if (signal.priceOpen <= 0) {
1460
+ // ЗАЩИТА ОТ NaN/Infinity: все цены должны быть конечными числами
1461
+ if (!isFinite(signal.priceOpen)) {
1462
+ errors.push(`priceOpen must be a finite number, got ${signal.priceOpen} (${typeof signal.priceOpen})`);
1463
+ }
1464
+ if (!isFinite(signal.priceTakeProfit)) {
1465
+ errors.push(`priceTakeProfit must be a finite number, got ${signal.priceTakeProfit} (${typeof signal.priceTakeProfit})`);
1466
+ }
1467
+ if (!isFinite(signal.priceStopLoss)) {
1468
+ errors.push(`priceStopLoss must be a finite number, got ${signal.priceStopLoss} (${typeof signal.priceStopLoss})`);
1469
+ }
1470
+ // Валидация цен (только если они конечные)
1471
+ if (isFinite(signal.priceOpen) && signal.priceOpen <= 0) {
1305
1472
  errors.push(`priceOpen must be positive, got ${signal.priceOpen}`);
1306
1473
  }
1307
- if (signal.priceTakeProfit <= 0) {
1474
+ if (isFinite(signal.priceTakeProfit) && signal.priceTakeProfit <= 0) {
1308
1475
  errors.push(`priceTakeProfit must be positive, got ${signal.priceTakeProfit}`);
1309
1476
  }
1310
- if (signal.priceStopLoss <= 0) {
1477
+ if (isFinite(signal.priceStopLoss) && signal.priceStopLoss <= 0) {
1311
1478
  errors.push(`priceStopLoss must be positive, got ${signal.priceStopLoss}`);
1312
1479
  }
1313
1480
  // Валидация для long позиции
@@ -1318,6 +1485,24 @@ const VALIDATE_SIGNAL_FN = (signal) => {
1318
1485
  if (signal.priceStopLoss >= signal.priceOpen) {
1319
1486
  errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
1320
1487
  }
1488
+ // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
1489
+ if (GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
1490
+ const tpDistancePercent = ((signal.priceTakeProfit - signal.priceOpen) / signal.priceOpen) * 100;
1491
+ if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
1492
+ errors.push(`Long: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
1493
+ `Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
1494
+ `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1495
+ }
1496
+ }
1497
+ // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1498
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1499
+ const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
1500
+ if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1501
+ errors.push(`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
1502
+ `Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
1503
+ `Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
1504
+ }
1505
+ }
1321
1506
  }
1322
1507
  // Валидация для short позиции
1323
1508
  if (signal.position === "short") {
@@ -1327,13 +1512,44 @@ const VALIDATE_SIGNAL_FN = (signal) => {
1327
1512
  if (signal.priceStopLoss <= signal.priceOpen) {
1328
1513
  errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
1329
1514
  }
1515
+ // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
1516
+ if (GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
1517
+ const tpDistancePercent = ((signal.priceOpen - signal.priceTakeProfit) / signal.priceOpen) * 100;
1518
+ if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
1519
+ errors.push(`Short: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
1520
+ `Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
1521
+ `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1522
+ }
1523
+ }
1524
+ // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1525
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1526
+ const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
1527
+ if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1528
+ errors.push(`Short: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
1529
+ `Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
1530
+ `Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
1531
+ }
1532
+ }
1330
1533
  }
1331
1534
  // Валидация временных параметров
1332
1535
  if (signal.minuteEstimatedTime <= 0) {
1333
1536
  errors.push(`minuteEstimatedTime must be positive, got ${signal.minuteEstimatedTime}`);
1334
1537
  }
1335
- if (signal.timestamp <= 0) {
1336
- errors.push(`timestamp must be positive, got ${signal.timestamp}`);
1538
+ // ЗАЩИТА ОТ ВЕЧНЫХ СИГНАЛОВ: ограничиваем максимальное время жизни сигнала
1539
+ if (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES && GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES) {
1540
+ if (signal.minuteEstimatedTime > GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES) {
1541
+ const days = (signal.minuteEstimatedTime / 60 / 24).toFixed(1);
1542
+ const maxDays = (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES / 60 / 24).toFixed(0);
1543
+ errors.push(`minuteEstimatedTime too large (${signal.minuteEstimatedTime} minutes = ${days} days). ` +
1544
+ `Maximum: ${GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES} minutes (${maxDays} days) to prevent strategy deadlock. ` +
1545
+ `Eternal signals block risk limits and prevent new trades.`);
1546
+ }
1547
+ }
1548
+ if (signal.scheduledAt <= 0) {
1549
+ errors.push(`scheduledAt must be positive, got ${signal.scheduledAt}`);
1550
+ }
1551
+ if (signal.pendingAt <= 0) {
1552
+ errors.push(`pendingAt must be positive, got ${signal.pendingAt}`);
1337
1553
  }
1338
1554
  // Кидаем ошибку если есть проблемы
1339
1555
  if (errors.length > 0) {
@@ -1369,6 +1585,27 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
1369
1585
  if (!signal) {
1370
1586
  return null;
1371
1587
  }
1588
+ // Если priceOpen указан - создаем scheduled signal (risk check при активации)
1589
+ if (signal.priceOpen !== undefined) {
1590
+ const scheduledSignalRow = {
1591
+ id: randomString(),
1592
+ priceOpen: signal.priceOpen,
1593
+ position: signal.position,
1594
+ note: signal.note,
1595
+ priceTakeProfit: signal.priceTakeProfit,
1596
+ priceStopLoss: signal.priceStopLoss,
1597
+ minuteEstimatedTime: signal.minuteEstimatedTime,
1598
+ symbol: self.params.execution.context.symbol,
1599
+ exchangeName: self.params.method.context.exchangeName,
1600
+ strategyName: self.params.method.context.strategyName,
1601
+ scheduledAt: currentTime,
1602
+ pendingAt: currentTime, // Временно, обновится при активации
1603
+ _isScheduled: true,
1604
+ };
1605
+ // Валидируем сигнал перед возвратом
1606
+ VALIDATE_SIGNAL_FN(scheduledSignalRow);
1607
+ return scheduledSignalRow;
1608
+ }
1372
1609
  const signalRow = {
1373
1610
  id: randomString(),
1374
1611
  priceOpen: currentPrice,
@@ -1376,7 +1613,9 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
1376
1613
  symbol: self.params.execution.context.symbol,
1377
1614
  exchangeName: self.params.method.context.exchangeName,
1378
1615
  strategyName: self.params.method.context.strategyName,
1379
- timestamp: currentTime,
1616
+ scheduledAt: currentTime,
1617
+ pendingAt: currentTime, // Для immediate signal оба времени одинаковые
1618
+ _isScheduled: false,
1380
1619
  };
1381
1620
  // Валидируем сигнал перед возвратом
1382
1621
  VALIDATE_SIGNAL_FN(signalRow);
@@ -1406,7 +1645,7 @@ const WAIT_FOR_INIT_FN$1 = async (self) => {
1406
1645
  if (self.params.execution.context.backtest) {
1407
1646
  return;
1408
1647
  }
1409
- const pendingSignal = await PersistSignalAdaper.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
1648
+ const pendingSignal = await PersistSignalAdapter.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
1410
1649
  if (!pendingSignal) {
1411
1650
  return;
1412
1651
  }
@@ -1417,6 +1656,563 @@ const WAIT_FOR_INIT_FN$1 = async (self) => {
1417
1656
  return;
1418
1657
  }
1419
1658
  self._pendingSignal = pendingSignal;
1659
+ // Call onActive callback for restored signal
1660
+ if (self.params.callbacks?.onActive) {
1661
+ const currentPrice = await self.params.exchange.getAveragePrice(self.params.execution.context.symbol);
1662
+ self.params.callbacks.onActive(self.params.execution.context.symbol, pendingSignal, currentPrice, self.params.execution.context.backtest);
1663
+ }
1664
+ };
1665
+ const CHECK_SCHEDULED_SIGNAL_TIMEOUT_FN = async (self, scheduled, currentPrice) => {
1666
+ const currentTime = self.params.execution.context.when.getTime();
1667
+ const signalTime = scheduled.scheduledAt; // Таймаут для scheduled signal считается от scheduledAt
1668
+ const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
1669
+ const elapsedTime = currentTime - signalTime;
1670
+ if (elapsedTime < maxTimeToWait) {
1671
+ return null;
1672
+ }
1673
+ self.params.logger.info("ClientStrategy scheduled signal cancelled by timeout", {
1674
+ symbol: self.params.execution.context.symbol,
1675
+ signalId: scheduled.id,
1676
+ elapsedMinutes: Math.floor(elapsedTime / 60000),
1677
+ maxMinutes: GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES,
1678
+ });
1679
+ self._scheduledSignal = null;
1680
+ if (self.params.callbacks?.onCancel) {
1681
+ self.params.callbacks.onCancel(self.params.execution.context.symbol, scheduled, currentPrice, self.params.execution.context.backtest);
1682
+ }
1683
+ const result = {
1684
+ action: "cancelled",
1685
+ signal: scheduled,
1686
+ currentPrice: currentPrice,
1687
+ closeTimestamp: currentTime,
1688
+ strategyName: self.params.method.context.strategyName,
1689
+ exchangeName: self.params.method.context.exchangeName,
1690
+ symbol: self.params.execution.context.symbol,
1691
+ };
1692
+ if (self.params.callbacks?.onTick) {
1693
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1694
+ }
1695
+ return result;
1696
+ };
1697
+ const CHECK_SCHEDULED_SIGNAL_PRICE_ACTIVATION_FN = (scheduled, currentPrice) => {
1698
+ let shouldActivate = false;
1699
+ let shouldCancel = false;
1700
+ if (scheduled.position === "long") {
1701
+ // КРИТИЧНО: Сначала проверяем StopLoss (отмена приоритетнее активации)
1702
+ // Отмена если цена упала СЛИШКОМ низко (ниже SL)
1703
+ if (currentPrice <= scheduled.priceStopLoss) {
1704
+ shouldCancel = true;
1705
+ }
1706
+ // Long = покупаем дешевле, ждем падения цены ДО priceOpen
1707
+ // Активируем только если НЕ пробит StopLoss
1708
+ else if (currentPrice <= scheduled.priceOpen) {
1709
+ shouldActivate = true;
1710
+ }
1711
+ }
1712
+ if (scheduled.position === "short") {
1713
+ // КРИТИЧНО: Сначала проверяем StopLoss (отмена приоритетнее активации)
1714
+ // Отмена если цена выросла СЛИШКОМ высоко (выше SL)
1715
+ if (currentPrice >= scheduled.priceStopLoss) {
1716
+ shouldCancel = true;
1717
+ }
1718
+ // Short = продаем дороже, ждем роста цены ДО priceOpen
1719
+ // Активируем только если НЕ пробит StopLoss
1720
+ else if (currentPrice >= scheduled.priceOpen) {
1721
+ shouldActivate = true;
1722
+ }
1723
+ }
1724
+ return { shouldActivate, shouldCancel };
1725
+ };
1726
+ const CANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN = async (self, scheduled, currentPrice) => {
1727
+ self.params.logger.info("ClientStrategy scheduled signal cancelled", {
1728
+ symbol: self.params.execution.context.symbol,
1729
+ signalId: scheduled.id,
1730
+ position: scheduled.position,
1731
+ averagePrice: currentPrice,
1732
+ priceStopLoss: scheduled.priceStopLoss,
1733
+ });
1734
+ self._scheduledSignal = null;
1735
+ const result = {
1736
+ action: "idle",
1737
+ signal: null,
1738
+ strategyName: self.params.method.context.strategyName,
1739
+ exchangeName: self.params.method.context.exchangeName,
1740
+ symbol: self.params.execution.context.symbol,
1741
+ currentPrice: currentPrice,
1742
+ };
1743
+ if (self.params.callbacks?.onTick) {
1744
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1745
+ }
1746
+ return result;
1747
+ };
1748
+ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp) => {
1749
+ // Check if strategy was stopped
1750
+ if (self._isStopped) {
1751
+ self.params.logger.info("ClientStrategy scheduled signal activation cancelled (stopped)", {
1752
+ symbol: self.params.execution.context.symbol,
1753
+ signalId: scheduled.id,
1754
+ });
1755
+ self._scheduledSignal = null;
1756
+ return null;
1757
+ }
1758
+ // В LIVE режиме activationTimestamp - это текущее время при tick()
1759
+ // В отличие от BACKTEST (где используется candle.timestamp + 60s),
1760
+ // здесь мы не знаем ТОЧНОЕ время достижения priceOpen,
1761
+ // поэтому используем время обнаружения активации
1762
+ const activationTime = activationTimestamp;
1763
+ self.params.logger.info("ClientStrategy scheduled signal activation begin", {
1764
+ symbol: self.params.execution.context.symbol,
1765
+ signalId: scheduled.id,
1766
+ position: scheduled.position,
1767
+ averagePrice: scheduled.priceOpen,
1768
+ priceOpen: scheduled.priceOpen,
1769
+ scheduledAt: scheduled.scheduledAt,
1770
+ pendingAt: activationTime,
1771
+ });
1772
+ if (await not(self.params.risk.checkSignal({
1773
+ symbol: self.params.execution.context.symbol,
1774
+ strategyName: self.params.method.context.strategyName,
1775
+ exchangeName: self.params.method.context.exchangeName,
1776
+ currentPrice: scheduled.priceOpen,
1777
+ timestamp: activationTime,
1778
+ }))) {
1779
+ self.params.logger.info("ClientStrategy scheduled signal rejected by risk", {
1780
+ symbol: self.params.execution.context.symbol,
1781
+ signalId: scheduled.id,
1782
+ });
1783
+ self._scheduledSignal = null;
1784
+ return null;
1785
+ }
1786
+ self._scheduledSignal = null;
1787
+ // КРИТИЧЕСКИ ВАЖНО: обновляем pendingAt при активации
1788
+ const activatedSignal = {
1789
+ ...scheduled,
1790
+ pendingAt: activationTime,
1791
+ _isScheduled: false,
1792
+ };
1793
+ await self.setPendingSignal(activatedSignal);
1794
+ await self.params.risk.addSignal(self.params.execution.context.symbol, {
1795
+ strategyName: self.params.method.context.strategyName,
1796
+ riskName: self.params.riskName,
1797
+ });
1798
+ if (self.params.callbacks?.onOpen) {
1799
+ self.params.callbacks.onOpen(self.params.execution.context.symbol, self._pendingSignal, self._pendingSignal.priceOpen, self.params.execution.context.backtest);
1800
+ }
1801
+ const result = {
1802
+ action: "opened",
1803
+ signal: self._pendingSignal,
1804
+ strategyName: self.params.method.context.strategyName,
1805
+ exchangeName: self.params.method.context.exchangeName,
1806
+ symbol: self.params.execution.context.symbol,
1807
+ currentPrice: self._pendingSignal.priceOpen,
1808
+ };
1809
+ if (self.params.callbacks?.onTick) {
1810
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1811
+ }
1812
+ return result;
1813
+ };
1814
+ const RETURN_SCHEDULED_SIGNAL_ACTIVE_FN = async (self, scheduled, currentPrice) => {
1815
+ const result = {
1816
+ action: "active",
1817
+ signal: scheduled,
1818
+ currentPrice: currentPrice,
1819
+ strategyName: self.params.method.context.strategyName,
1820
+ exchangeName: self.params.method.context.exchangeName,
1821
+ symbol: self.params.execution.context.symbol,
1822
+ };
1823
+ if (self.params.callbacks?.onTick) {
1824
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1825
+ }
1826
+ return result;
1827
+ };
1828
+ const OPEN_NEW_SCHEDULED_SIGNAL_FN = async (self, signal) => {
1829
+ const currentPrice = await self.params.exchange.getAveragePrice(self.params.execution.context.symbol);
1830
+ self.params.logger.info("ClientStrategy scheduled signal created", {
1831
+ symbol: self.params.execution.context.symbol,
1832
+ signalId: signal.id,
1833
+ position: signal.position,
1834
+ priceOpen: signal.priceOpen,
1835
+ currentPrice: currentPrice,
1836
+ });
1837
+ if (self.params.callbacks?.onSchedule) {
1838
+ self.params.callbacks.onSchedule(self.params.execution.context.symbol, signal, currentPrice, self.params.execution.context.backtest);
1839
+ }
1840
+ const result = {
1841
+ action: "scheduled",
1842
+ signal: signal,
1843
+ strategyName: self.params.method.context.strategyName,
1844
+ exchangeName: self.params.method.context.exchangeName,
1845
+ symbol: self.params.execution.context.symbol,
1846
+ currentPrice: currentPrice,
1847
+ };
1848
+ if (self.params.callbacks?.onTick) {
1849
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1850
+ }
1851
+ return result;
1852
+ };
1853
+ const OPEN_NEW_PENDING_SIGNAL_FN = async (self, signal) => {
1854
+ if (await not(self.params.risk.checkSignal({
1855
+ symbol: self.params.execution.context.symbol,
1856
+ strategyName: self.params.method.context.strategyName,
1857
+ exchangeName: self.params.method.context.exchangeName,
1858
+ currentPrice: signal.priceOpen,
1859
+ timestamp: self.params.execution.context.when.getTime(),
1860
+ }))) {
1861
+ return null;
1862
+ }
1863
+ await self.params.risk.addSignal(self.params.execution.context.symbol, {
1864
+ strategyName: self.params.method.context.strategyName,
1865
+ riskName: self.params.riskName,
1866
+ });
1867
+ if (self.params.callbacks?.onOpen) {
1868
+ self.params.callbacks.onOpen(self.params.execution.context.symbol, signal, signal.priceOpen, self.params.execution.context.backtest);
1869
+ }
1870
+ const result = {
1871
+ action: "opened",
1872
+ signal: signal,
1873
+ strategyName: self.params.method.context.strategyName,
1874
+ exchangeName: self.params.method.context.exchangeName,
1875
+ symbol: self.params.execution.context.symbol,
1876
+ currentPrice: signal.priceOpen,
1877
+ };
1878
+ if (self.params.callbacks?.onTick) {
1879
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1880
+ }
1881
+ return result;
1882
+ };
1883
+ const CHECK_PENDING_SIGNAL_COMPLETION_FN = async (self, signal, averagePrice) => {
1884
+ const currentTime = self.params.execution.context.when.getTime();
1885
+ const signalTime = signal.pendingAt; // КРИТИЧНО: используем pendingAt, а не scheduledAt!
1886
+ const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
1887
+ const elapsedTime = currentTime - signalTime;
1888
+ // Check time expiration
1889
+ if (elapsedTime >= maxTimeToWait) {
1890
+ return await CLOSE_PENDING_SIGNAL_FN(self, signal, averagePrice, "time_expired");
1891
+ }
1892
+ // Check take profit
1893
+ if (signal.position === "long" && averagePrice >= signal.priceTakeProfit) {
1894
+ return await CLOSE_PENDING_SIGNAL_FN(self, signal, signal.priceTakeProfit, // КРИТИЧНО: используем точную цену TP
1895
+ "take_profit");
1896
+ }
1897
+ if (signal.position === "short" && averagePrice <= signal.priceTakeProfit) {
1898
+ return await CLOSE_PENDING_SIGNAL_FN(self, signal, signal.priceTakeProfit, // КРИТИЧНО: используем точную цену TP
1899
+ "take_profit");
1900
+ }
1901
+ // Check stop loss
1902
+ if (signal.position === "long" && averagePrice <= signal.priceStopLoss) {
1903
+ return await CLOSE_PENDING_SIGNAL_FN(self, signal, signal.priceStopLoss, // КРИТИЧНО: используем точную цену SL
1904
+ "stop_loss");
1905
+ }
1906
+ if (signal.position === "short" && averagePrice >= signal.priceStopLoss) {
1907
+ return await CLOSE_PENDING_SIGNAL_FN(self, signal, signal.priceStopLoss, // КРИТИЧНО: используем точную цену SL
1908
+ "stop_loss");
1909
+ }
1910
+ return null;
1911
+ };
1912
+ const CLOSE_PENDING_SIGNAL_FN = async (self, signal, currentPrice, closeReason) => {
1913
+ const pnl = toProfitLossDto(signal, currentPrice);
1914
+ self.params.logger.info(`ClientStrategy signal ${closeReason}`, {
1915
+ symbol: self.params.execution.context.symbol,
1916
+ signalId: signal.id,
1917
+ closeReason,
1918
+ priceClose: currentPrice,
1919
+ pnlPercentage: pnl.pnlPercentage,
1920
+ });
1921
+ if (self.params.callbacks?.onClose) {
1922
+ self.params.callbacks.onClose(self.params.execution.context.symbol, signal, currentPrice, self.params.execution.context.backtest);
1923
+ }
1924
+ await self.params.risk.removeSignal(self.params.execution.context.symbol, {
1925
+ strategyName: self.params.method.context.strategyName,
1926
+ riskName: self.params.riskName,
1927
+ });
1928
+ await self.setPendingSignal(null);
1929
+ const result = {
1930
+ action: "closed",
1931
+ signal: signal,
1932
+ currentPrice: currentPrice,
1933
+ closeReason: closeReason,
1934
+ closeTimestamp: self.params.execution.context.when.getTime(),
1935
+ pnl: pnl,
1936
+ strategyName: self.params.method.context.strategyName,
1937
+ exchangeName: self.params.method.context.exchangeName,
1938
+ symbol: self.params.execution.context.symbol,
1939
+ };
1940
+ if (self.params.callbacks?.onTick) {
1941
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1942
+ }
1943
+ return result;
1944
+ };
1945
+ const RETURN_PENDING_SIGNAL_ACTIVE_FN = async (self, signal, currentPrice) => {
1946
+ const result = {
1947
+ action: "active",
1948
+ signal: signal,
1949
+ currentPrice: currentPrice,
1950
+ strategyName: self.params.method.context.strategyName,
1951
+ exchangeName: self.params.method.context.exchangeName,
1952
+ symbol: self.params.execution.context.symbol,
1953
+ };
1954
+ if (self.params.callbacks?.onTick) {
1955
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1956
+ }
1957
+ return result;
1958
+ };
1959
+ const RETURN_IDLE_FN = async (self, currentPrice) => {
1960
+ if (self.params.callbacks?.onIdle) {
1961
+ self.params.callbacks.onIdle(self.params.execution.context.symbol, currentPrice, self.params.execution.context.backtest);
1962
+ }
1963
+ const result = {
1964
+ action: "idle",
1965
+ signal: null,
1966
+ strategyName: self.params.method.context.strategyName,
1967
+ exchangeName: self.params.method.context.exchangeName,
1968
+ symbol: self.params.execution.context.symbol,
1969
+ currentPrice: currentPrice,
1970
+ };
1971
+ if (self.params.callbacks?.onTick) {
1972
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1973
+ }
1974
+ return result;
1975
+ };
1976
+ const CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, averagePrice, closeTimestamp) => {
1977
+ self.params.logger.info("ClientStrategy backtest scheduled signal cancelled", {
1978
+ symbol: self.params.execution.context.symbol,
1979
+ signalId: scheduled.id,
1980
+ closeTimestamp,
1981
+ averagePrice,
1982
+ priceStopLoss: scheduled.priceStopLoss,
1983
+ });
1984
+ self._scheduledSignal = null;
1985
+ if (self.params.callbacks?.onCancel) {
1986
+ self.params.callbacks.onCancel(self.params.execution.context.symbol, scheduled, averagePrice, self.params.execution.context.backtest);
1987
+ }
1988
+ const result = {
1989
+ action: "cancelled",
1990
+ signal: scheduled,
1991
+ currentPrice: averagePrice,
1992
+ closeTimestamp: closeTimestamp,
1993
+ strategyName: self.params.method.context.strategyName,
1994
+ exchangeName: self.params.method.context.exchangeName,
1995
+ symbol: self.params.execution.context.symbol,
1996
+ };
1997
+ if (self.params.callbacks?.onTick) {
1998
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
1999
+ }
2000
+ return result;
2001
+ };
2002
+ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activationTimestamp) => {
2003
+ // Check if strategy was stopped
2004
+ if (self._isStopped) {
2005
+ self.params.logger.info("ClientStrategy backtest scheduled signal activation cancelled (stopped)", {
2006
+ symbol: self.params.execution.context.symbol,
2007
+ signalId: scheduled.id,
2008
+ });
2009
+ self._scheduledSignal = null;
2010
+ return false;
2011
+ }
2012
+ // В BACKTEST режиме activationTimestamp - это candle.timestamp + 60*1000
2013
+ // (timestamp СЛЕДУЮЩЕЙ свечи после достижения priceOpen)
2014
+ // Это обеспечивает точный расчёт minuteEstimatedTime от момента активации
2015
+ const activationTime = activationTimestamp;
2016
+ self.params.logger.info("ClientStrategy backtest scheduled signal activated", {
2017
+ symbol: self.params.execution.context.symbol,
2018
+ signalId: scheduled.id,
2019
+ priceOpen: scheduled.priceOpen,
2020
+ scheduledAt: scheduled.scheduledAt,
2021
+ pendingAt: activationTime,
2022
+ });
2023
+ if (await not(self.params.risk.checkSignal({
2024
+ symbol: self.params.execution.context.symbol,
2025
+ strategyName: self.params.method.context.strategyName,
2026
+ exchangeName: self.params.method.context.exchangeName,
2027
+ currentPrice: scheduled.priceOpen,
2028
+ timestamp: activationTime,
2029
+ }))) {
2030
+ self.params.logger.info("ClientStrategy backtest scheduled signal rejected by risk", {
2031
+ symbol: self.params.execution.context.symbol,
2032
+ signalId: scheduled.id,
2033
+ });
2034
+ self._scheduledSignal = null;
2035
+ return false;
2036
+ }
2037
+ self._scheduledSignal = null;
2038
+ // КРИТИЧЕСКИ ВАЖНО: обновляем pendingAt при активации в backtest
2039
+ const activatedSignal = {
2040
+ ...scheduled,
2041
+ pendingAt: activationTime,
2042
+ _isScheduled: false,
2043
+ };
2044
+ await self.setPendingSignal(activatedSignal);
2045
+ await self.params.risk.addSignal(self.params.execution.context.symbol, {
2046
+ strategyName: self.params.method.context.strategyName,
2047
+ riskName: self.params.riskName,
2048
+ });
2049
+ if (self.params.callbacks?.onOpen) {
2050
+ self.params.callbacks.onOpen(self.params.execution.context.symbol, activatedSignal, activatedSignal.priceOpen, self.params.execution.context.backtest);
2051
+ }
2052
+ return true;
2053
+ };
2054
+ const CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, signal, averagePrice, closeReason, closeTimestamp) => {
2055
+ const pnl = toProfitLossDto(signal, averagePrice);
2056
+ self.params.logger.debug(`ClientStrategy backtest ${closeReason}`, {
2057
+ symbol: self.params.execution.context.symbol,
2058
+ signalId: signal.id,
2059
+ reason: closeReason,
2060
+ priceClose: averagePrice,
2061
+ closeTimestamp,
2062
+ pnlPercentage: pnl.pnlPercentage,
2063
+ });
2064
+ if (closeReason === "stop_loss") {
2065
+ self.params.logger.warn(`ClientStrategy backtest: Signal closed with loss (stop_loss), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
2066
+ }
2067
+ if (closeReason === "time_expired" && pnl.pnlPercentage < 0) {
2068
+ self.params.logger.warn(`ClientStrategy backtest: Signal closed with loss (time_expired), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
2069
+ }
2070
+ if (self.params.callbacks?.onClose) {
2071
+ self.params.callbacks.onClose(self.params.execution.context.symbol, signal, averagePrice, self.params.execution.context.backtest);
2072
+ }
2073
+ await self.params.risk.removeSignal(self.params.execution.context.symbol, {
2074
+ strategyName: self.params.method.context.strategyName,
2075
+ riskName: self.params.riskName,
2076
+ });
2077
+ await self.setPendingSignal(null);
2078
+ const result = {
2079
+ action: "closed",
2080
+ signal: signal,
2081
+ currentPrice: averagePrice,
2082
+ closeReason: closeReason,
2083
+ closeTimestamp: closeTimestamp,
2084
+ pnl: pnl,
2085
+ strategyName: self.params.method.context.strategyName,
2086
+ exchangeName: self.params.method.context.exchangeName,
2087
+ symbol: self.params.execution.context.symbol,
2088
+ };
2089
+ if (self.params.callbacks?.onTick) {
2090
+ self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
2091
+ }
2092
+ return result;
2093
+ };
2094
+ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
2095
+ const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2096
+ const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
2097
+ for (let i = 0; i < candles.length; i++) {
2098
+ const candle = candles[i];
2099
+ const recentCandles = candles.slice(Math.max(0, i - (candlesCount - 1)), i + 1);
2100
+ const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2101
+ // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
2102
+ const elapsedTime = candle.timestamp - scheduled.scheduledAt;
2103
+ if (elapsedTime >= maxTimeToWait) {
2104
+ const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp);
2105
+ return { activated: false, cancelled: true, activationIndex: i, result };
2106
+ }
2107
+ let shouldActivate = false;
2108
+ let shouldCancel = false;
2109
+ if (scheduled.position === "long") {
2110
+ // КРИТИЧНО для LONG:
2111
+ // - priceOpen > priceStopLoss (по валидации)
2112
+ // - Активация: low <= priceOpen (цена упала до входа)
2113
+ // - Отмена: low <= priceStopLoss (цена пробила SL)
2114
+ //
2115
+ // EDGE CASE: если low <= priceStopLoss И low <= priceOpen на ОДНОЙ свече:
2116
+ // => Отмена имеет ПРИОРИТЕТ! (SL пробит ДО или ВМЕСТЕ с активацией)
2117
+ // Сигнал НЕ открывается, сразу отменяется
2118
+ if (candle.low <= scheduled.priceStopLoss) {
2119
+ shouldCancel = true;
2120
+ }
2121
+ else if (candle.low <= scheduled.priceOpen) {
2122
+ shouldActivate = true;
2123
+ }
2124
+ }
2125
+ if (scheduled.position === "short") {
2126
+ // КРИТИЧНО для SHORT:
2127
+ // - priceOpen < priceStopLoss (по валидации)
2128
+ // - Активация: high >= priceOpen (цена выросла до входа)
2129
+ // - Отмена: high >= priceStopLoss (цена пробила SL)
2130
+ //
2131
+ // EDGE CASE: если high >= priceStopLoss И high >= priceOpen на ОДНОЙ свече:
2132
+ // => Отмена имеет ПРИОРИТЕТ! (SL пробит ДО или ВМЕСТЕ с активацией)
2133
+ // Сигнал НЕ открывается, сразу отменяется
2134
+ if (candle.high >= scheduled.priceStopLoss) {
2135
+ shouldCancel = true;
2136
+ }
2137
+ else if (candle.high >= scheduled.priceOpen) {
2138
+ shouldActivate = true;
2139
+ }
2140
+ }
2141
+ if (shouldCancel) {
2142
+ const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp);
2143
+ return { activated: false, cancelled: true, activationIndex: i, result };
2144
+ }
2145
+ if (shouldActivate) {
2146
+ await ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, candle.timestamp);
2147
+ return {
2148
+ activated: true,
2149
+ cancelled: false,
2150
+ activationIndex: i,
2151
+ result: null,
2152
+ };
2153
+ }
2154
+ }
2155
+ return {
2156
+ activated: false,
2157
+ cancelled: false,
2158
+ activationIndex: -1,
2159
+ result: null,
2160
+ };
2161
+ };
2162
+ const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
2163
+ const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2164
+ for (let i = candlesCount - 1; i < candles.length; i++) {
2165
+ const recentCandles = candles.slice(i - (candlesCount - 1), i + 1);
2166
+ const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2167
+ const currentCandleTimestamp = recentCandles[recentCandles.length - 1].timestamp;
2168
+ const currentCandle = recentCandles[recentCandles.length - 1];
2169
+ let shouldClose = false;
2170
+ let closeReason;
2171
+ // Check time expiration FIRST (КРИТИЧНО!)
2172
+ const signalTime = signal.pendingAt;
2173
+ const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
2174
+ const elapsedTime = currentCandleTimestamp - signalTime;
2175
+ if (elapsedTime >= maxTimeToWait) {
2176
+ shouldClose = true;
2177
+ closeReason = "time_expired";
2178
+ }
2179
+ // Check TP/SL only if not expired
2180
+ // КРИТИЧНО: используем candle.high/low для точной проверки достижения TP/SL
2181
+ if (!shouldClose && signal.position === "long") {
2182
+ // Для LONG: TP срабатывает если high >= TP, SL если low <= SL
2183
+ if (currentCandle.high >= signal.priceTakeProfit) {
2184
+ shouldClose = true;
2185
+ closeReason = "take_profit";
2186
+ }
2187
+ else if (currentCandle.low <= signal.priceStopLoss) {
2188
+ shouldClose = true;
2189
+ closeReason = "stop_loss";
2190
+ }
2191
+ }
2192
+ if (!shouldClose && signal.position === "short") {
2193
+ // Для SHORT: TP срабатывает если low <= TP, SL если high >= SL
2194
+ if (currentCandle.low <= signal.priceTakeProfit) {
2195
+ shouldClose = true;
2196
+ closeReason = "take_profit";
2197
+ }
2198
+ else if (currentCandle.high >= signal.priceStopLoss) {
2199
+ shouldClose = true;
2200
+ closeReason = "stop_loss";
2201
+ }
2202
+ }
2203
+ if (shouldClose) {
2204
+ // КРИТИЧНО: при закрытии по TP/SL используем точную цену, а не averagePrice
2205
+ let closePrice = averagePrice;
2206
+ if (closeReason === "take_profit") {
2207
+ closePrice = signal.priceTakeProfit;
2208
+ }
2209
+ else if (closeReason === "stop_loss") {
2210
+ closePrice = signal.priceStopLoss;
2211
+ }
2212
+ return await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(self, signal, closePrice, closeReason, currentCandleTimestamp);
2213
+ }
2214
+ }
2215
+ return null;
1420
2216
  };
1421
2217
  /**
1422
2218
  * Client implementation for trading strategy lifecycle management.
@@ -1450,6 +2246,7 @@ class ClientStrategy {
1450
2246
  this.params = params;
1451
2247
  this._isStopped = false;
1452
2248
  this._pendingSignal = null;
2249
+ this._scheduledSignal = null;
1453
2250
  this._lastSignalTimestamp = null;
1454
2251
  /**
1455
2252
  * Initializes strategy state by loading persisted signal from disk.
@@ -1476,22 +2273,34 @@ class ClientStrategy {
1476
2273
  pendingSignal,
1477
2274
  });
1478
2275
  this._pendingSignal = pendingSignal;
2276
+ // КРИТИЧНО: Всегда вызываем коллбек onWrite для тестирования persist storage
2277
+ // даже в backtest режиме, чтобы тесты могли перехватывать вызовы через mock adapter
2278
+ if (this.params.callbacks?.onWrite) {
2279
+ this.params.callbacks.onWrite(this.params.execution.context.symbol, this._pendingSignal, this.params.execution.context.backtest);
2280
+ }
1479
2281
  if (this.params.execution.context.backtest) {
1480
2282
  return;
1481
2283
  }
1482
- await PersistSignalAdaper.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
2284
+ await PersistSignalAdapter.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
1483
2285
  }
1484
2286
  /**
1485
2287
  * Performs a single tick of strategy execution.
1486
2288
  *
1487
- * Flow:
1488
- * 1. If no pending signal: call getSignal with throttling and validation
1489
- * 2. If signal opened: trigger onOpen callback, persist state
1490
- * 3. If pending signal exists: check VWAP against TP/SL
1491
- * 4. If TP/SL/time reached: close signal, trigger onClose, persist state
2289
+ * Flow (LIVE mode):
2290
+ * 1. If scheduled signal exists: check activation/cancellation
2291
+ * 2. If no pending/scheduled signal: call getSignal with throttling and validation
2292
+ * 3. If signal opened: trigger onOpen callback, persist state
2293
+ * 4. If pending signal exists: check VWAP against TP/SL
2294
+ * 5. If TP/SL/time reached: close signal, trigger onClose, persist state
2295
+ *
2296
+ * Flow (BACKTEST mode):
2297
+ * 1. If no pending/scheduled signal: call getSignal
2298
+ * 2. If scheduled signal created: return "scheduled" (backtest() will handle it)
2299
+ * 3. Otherwise same as LIVE
1492
2300
  *
1493
2301
  * @returns Promise resolving to discriminated union result:
1494
2302
  * - idle: No signal generated
2303
+ * - scheduled: Scheduled signal created (backtest only)
1495
2304
  * - opened: New signal just created
1496
2305
  * - active: Signal monitoring in progress
1497
2306
  * - closed: Signal completed with PNL
@@ -1506,301 +2315,195 @@ class ClientStrategy {
1506
2315
  */
1507
2316
  async tick() {
1508
2317
  this.params.logger.debug("ClientStrategy tick");
1509
- if (!this._pendingSignal) {
1510
- const pendingSignal = await GET_SIGNAL_FN(this);
1511
- await this.setPendingSignal(pendingSignal);
1512
- if (this._pendingSignal) {
1513
- // Register signal with risk management
1514
- await this.params.risk.addSignal(this.params.execution.context.symbol, {
1515
- strategyName: this.params.method.context.strategyName,
1516
- riskName: this.params.riskName,
1517
- });
1518
- if (this.params.callbacks?.onOpen) {
1519
- this.params.callbacks.onOpen(this.params.execution.context.symbol, this._pendingSignal, this._pendingSignal.priceOpen, this.params.execution.context.backtest);
1520
- }
1521
- const result = {
1522
- action: "opened",
1523
- signal: this._pendingSignal,
1524
- strategyName: this.params.method.context.strategyName,
1525
- exchangeName: this.params.method.context.exchangeName,
1526
- symbol: this.params.execution.context.symbol,
1527
- currentPrice: this._pendingSignal.priceOpen,
1528
- };
1529
- if (this.params.callbacks?.onTick) {
1530
- this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
1531
- }
1532
- return result;
1533
- }
2318
+ // Получаем текущее время в начале tick для консистентности
2319
+ const currentTime = this.params.execution.context.when.getTime();
2320
+ // Early return if strategy was stopped
2321
+ if (this._isStopped) {
1534
2322
  const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
1535
- if (this.params.callbacks?.onIdle) {
1536
- this.params.callbacks.onIdle(this.params.execution.context.symbol, currentPrice, this.params.execution.context.backtest);
1537
- }
1538
- const result = {
1539
- action: "idle",
1540
- signal: null,
1541
- strategyName: this.params.method.context.strategyName,
1542
- exchangeName: this.params.method.context.exchangeName,
1543
- symbol: this.params.execution.context.symbol,
1544
- currentPrice,
1545
- };
1546
- if (this.params.callbacks?.onTick) {
1547
- this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
1548
- }
1549
- return result;
1550
- }
1551
- const when = this.params.execution.context.when;
1552
- const signal = this._pendingSignal;
1553
- // Получаем среднюю цену
1554
- const averagePrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
1555
- this.params.logger.debug("ClientStrategy tick check", {
1556
- symbol: this.params.execution.context.symbol,
1557
- averagePrice,
1558
- signalId: signal.id,
1559
- position: signal.position,
1560
- });
1561
- let shouldClose = false;
1562
- let closeReason;
1563
- // Проверяем истечение времени
1564
- const signalEndTime = signal.timestamp + signal.minuteEstimatedTime * 60 * 1000;
1565
- if (when.getTime() >= signalEndTime) {
1566
- shouldClose = true;
1567
- closeReason = "time_expired";
1568
- }
1569
- // Проверяем достижение TP/SL для long позиции
1570
- if (signal.position === "long") {
1571
- if (averagePrice >= signal.priceTakeProfit) {
1572
- shouldClose = true;
1573
- closeReason = "take_profit";
1574
- }
1575
- else if (averagePrice <= signal.priceStopLoss) {
1576
- shouldClose = true;
1577
- closeReason = "stop_loss";
1578
- }
2323
+ return await RETURN_IDLE_FN(this, currentPrice);
1579
2324
  }
1580
- // Проверяем достижение TP/SL для short позиции
1581
- if (signal.position === "short") {
1582
- if (averagePrice <= signal.priceTakeProfit) {
1583
- shouldClose = true;
1584
- closeReason = "take_profit";
2325
+ // Monitor scheduled signal
2326
+ if (this._scheduledSignal && !this._pendingSignal) {
2327
+ const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
2328
+ // Check timeout
2329
+ const timeoutResult = await CHECK_SCHEDULED_SIGNAL_TIMEOUT_FN(this, this._scheduledSignal, currentPrice);
2330
+ if (timeoutResult)
2331
+ return timeoutResult;
2332
+ // Check price-based activation/cancellation
2333
+ const { shouldActivate, shouldCancel } = CHECK_SCHEDULED_SIGNAL_PRICE_ACTIVATION_FN(this._scheduledSignal, currentPrice);
2334
+ if (shouldCancel) {
2335
+ return await CANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN(this, this._scheduledSignal, currentPrice);
1585
2336
  }
1586
- else if (averagePrice >= signal.priceStopLoss) {
1587
- shouldClose = true;
1588
- closeReason = "stop_loss";
2337
+ if (shouldActivate) {
2338
+ const activateResult = await ACTIVATE_SCHEDULED_SIGNAL_FN(this, this._scheduledSignal, currentTime);
2339
+ if (activateResult) {
2340
+ return activateResult;
2341
+ }
2342
+ // Risk rejected or stopped - return idle
2343
+ return await RETURN_IDLE_FN(this, currentPrice);
1589
2344
  }
2345
+ return await RETURN_SCHEDULED_SIGNAL_ACTIVE_FN(this, this._scheduledSignal, currentPrice);
1590
2346
  }
1591
- // Закрываем сигнал если выполнены условия
1592
- if (shouldClose) {
1593
- const pnl = toProfitLossDto(signal, averagePrice);
1594
- const closeTimestamp = this.params.execution.context.when.getTime();
1595
- // Предупреждение о закрытии сигнала в убыток
1596
- if (closeReason === "stop_loss") {
1597
- this.params.logger.warn(`ClientStrategy tick: Signal closed with loss (stop_loss), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
1598
- }
1599
- // Предупреждение о закрытии сигнала в убыток
1600
- if (closeReason === "time_expired" && pnl.pnlPercentage < 0) {
1601
- this.params.logger.warn(`ClientStrategy tick: Signal closed with loss (time_expired), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
1602
- }
1603
- this.params.logger.debug("ClientStrategy closing", {
1604
- symbol: this.params.execution.context.symbol,
1605
- signalId: signal.id,
1606
- reason: closeReason,
1607
- priceClose: averagePrice,
1608
- closeTimestamp,
1609
- pnlPercentage: pnl.pnlPercentage,
1610
- });
1611
- if (this.params.callbacks?.onClose) {
1612
- this.params.callbacks.onClose(this.params.execution.context.symbol, signal, averagePrice, this.params.execution.context.backtest);
2347
+ // Generate new signal if none exists
2348
+ if (!this._pendingSignal && !this._scheduledSignal) {
2349
+ const signal = await GET_SIGNAL_FN(this);
2350
+ if (signal) {
2351
+ if (signal._isScheduled === true) {
2352
+ this._scheduledSignal = signal;
2353
+ return await OPEN_NEW_SCHEDULED_SIGNAL_FN(this, this._scheduledSignal);
2354
+ }
2355
+ await this.setPendingSignal(signal);
1613
2356
  }
1614
- // Remove signal from risk management
1615
- await this.params.risk.removeSignal(this.params.execution.context.symbol, {
1616
- strategyName: this.params.method.context.strategyName,
1617
- riskName: this.params.riskName,
1618
- });
1619
- await this.setPendingSignal(null);
1620
- const result = {
1621
- action: "closed",
1622
- signal: signal,
1623
- currentPrice: averagePrice,
1624
- closeReason: closeReason,
1625
- closeTimestamp: closeTimestamp,
1626
- pnl: pnl,
1627
- strategyName: this.params.method.context.strategyName,
1628
- exchangeName: this.params.method.context.exchangeName,
1629
- symbol: this.params.execution.context.symbol,
1630
- };
1631
- if (this.params.callbacks?.onTick) {
1632
- this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
2357
+ if (this._pendingSignal) {
2358
+ const openResult = await OPEN_NEW_PENDING_SIGNAL_FN(this, this._pendingSignal);
2359
+ if (openResult) {
2360
+ return openResult;
2361
+ }
2362
+ // Risk rejected - clear pending signal and return idle
2363
+ await this.setPendingSignal(null);
1633
2364
  }
1634
- return result;
1635
- }
1636
- if (this.params.callbacks?.onActive) {
1637
- this.params.callbacks.onActive(this.params.execution.context.symbol, signal, averagePrice, this.params.execution.context.backtest);
2365
+ const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
2366
+ return await RETURN_IDLE_FN(this, currentPrice);
1638
2367
  }
1639
- const result = {
1640
- action: "active",
1641
- signal: signal,
1642
- currentPrice: averagePrice,
1643
- strategyName: this.params.method.context.strategyName,
1644
- exchangeName: this.params.method.context.exchangeName,
1645
- symbol: this.params.execution.context.symbol,
1646
- };
1647
- if (this.params.callbacks?.onTick) {
1648
- this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
2368
+ // Monitor pending signal
2369
+ const averagePrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
2370
+ const closedResult = await CHECK_PENDING_SIGNAL_COMPLETION_FN(this, this._pendingSignal, averagePrice);
2371
+ if (closedResult) {
2372
+ return closedResult;
1649
2373
  }
1650
- return result;
2374
+ return await RETURN_PENDING_SIGNAL_ACTIVE_FN(this, this._pendingSignal, averagePrice);
1651
2375
  }
1652
2376
  /**
1653
- * Fast backtests a pending signal using historical candle data.
2377
+ * Fast backtests a signal using historical candle data.
2378
+ *
2379
+ * For scheduled signals:
2380
+ * 1. Iterates through candles checking for activation (price reaches priceOpen)
2381
+ * 2. Or cancellation (price hits StopLoss before activation)
2382
+ * 3. If activated: converts to pending signal and continues with TP/SL monitoring
2383
+ * 4. If cancelled: returns closed result with closeReason "cancelled"
1654
2384
  *
1655
- * Iterates through candles checking VWAP against TP/SL on each timeframe.
1656
- * Starts from index 4 (needs 5 candles for VWAP calculation).
1657
- * Always returns closed result (either TP/SL or time_expired).
2385
+ * For pending signals:
2386
+ * 1. Iterates through candles checking VWAP against TP/SL on each timeframe
2387
+ * 2. Starts from index 4 (needs 5 candles for VWAP calculation)
2388
+ * 3. Returns closed result (either TP/SL or time_expired)
1658
2389
  *
1659
- * @param candles - Array of candles covering signal's minuteEstimatedTime
2390
+ * @param candles - Array of candles to process
1660
2391
  * @returns Promise resolving to closed signal result with PNL
1661
- * @throws Error if no pending signal or not in backtest mode
2392
+ * @throws Error if no pending/scheduled signal or not in backtest mode
1662
2393
  *
1663
2394
  * @example
1664
2395
  * ```typescript
1665
2396
  * // After signal opened in backtest
1666
2397
  * const candles = await exchange.getNextCandles("BTCUSDT", "1m", signal.minuteEstimatedTime);
1667
2398
  * const result = await strategy.backtest(candles);
1668
- * console.log(result.closeReason); // "take_profit" | "stop_loss" | "time_expired"
2399
+ * console.log(result.closeReason); // "take_profit" | "stop_loss" | "time_expired" | "cancelled"
1669
2400
  * ```
1670
2401
  */
1671
2402
  async backtest(candles) {
1672
2403
  this.params.logger.debug("ClientStrategy backtest", {
1673
2404
  symbol: this.params.execution.context.symbol,
1674
2405
  candlesCount: candles.length,
2406
+ hasScheduled: !!this._scheduledSignal,
2407
+ hasPending: !!this._pendingSignal,
1675
2408
  });
1676
- const signal = this._pendingSignal;
1677
- if (!signal) {
1678
- throw new Error("ClientStrategy backtest: no pending signal");
1679
- }
1680
2409
  if (!this.params.execution.context.backtest) {
1681
2410
  throw new Error("ClientStrategy backtest: running in live context");
1682
2411
  }
1683
- // Предупреждение если недостаточно свечей для VWAP
1684
- if (candles.length < 5) {
1685
- this.params.logger.warn(`ClientStrategy backtest: Expected at least 5 candles for VWAP, got ${candles.length}`);
1686
- }
1687
- // Проверяем каждую свечу на достижение TP/SL
1688
- // Начинаем с индекса 4 (пятая свеча), чтобы было минимум 5 свечей для VWAP
1689
- for (let i = 4; i < candles.length; i++) {
1690
- // Вычисляем VWAP из последних 5 свечей для текущего момента
1691
- const recentCandles = candles.slice(i - 4, i + 1);
1692
- const averagePrice = GET_AVG_PRICE_FN(recentCandles);
1693
- let shouldClose = false;
1694
- let closeReason;
1695
- // Проверяем достижение TP/SL для long позиции
1696
- if (signal.position === "long") {
1697
- if (averagePrice >= signal.priceTakeProfit) {
1698
- shouldClose = true;
1699
- closeReason = "take_profit";
1700
- }
1701
- else if (averagePrice <= signal.priceStopLoss) {
1702
- shouldClose = true;
1703
- closeReason = "stop_loss";
1704
- }
2412
+ if (!this._pendingSignal && !this._scheduledSignal) {
2413
+ throw new Error("ClientStrategy backtest: no pending or scheduled signal");
2414
+ }
2415
+ // Process scheduled signal
2416
+ if (this._scheduledSignal && !this._pendingSignal) {
2417
+ const scheduled = this._scheduledSignal;
2418
+ this.params.logger.debug("ClientStrategy backtest scheduled signal", {
2419
+ symbol: this.params.execution.context.symbol,
2420
+ signalId: scheduled.id,
2421
+ priceOpen: scheduled.priceOpen,
2422
+ position: scheduled.position,
2423
+ });
2424
+ const { activated, cancelled, activationIndex, result } = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles);
2425
+ if (cancelled && result) {
2426
+ return result;
1705
2427
  }
1706
- // Проверяем достижение TP/SL для short позиции
1707
- if (signal.position === "short") {
1708
- if (averagePrice <= signal.priceTakeProfit) {
1709
- shouldClose = true;
1710
- closeReason = "take_profit";
1711
- }
1712
- else if (averagePrice >= signal.priceStopLoss) {
1713
- shouldClose = true;
1714
- closeReason = "stop_loss";
2428
+ if (activated) {
2429
+ const remainingCandles = candles.slice(activationIndex + 1);
2430
+ if (remainingCandles.length === 0) {
2431
+ const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2432
+ const recentCandles = candles.slice(Math.max(0, activationIndex - (candlesCount - 1)), activationIndex + 1);
2433
+ const lastPrice = GET_AVG_PRICE_FN(recentCandles);
2434
+ const closeTimestamp = candles[activationIndex].timestamp;
2435
+ return await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(this, scheduled, lastPrice, "time_expired", closeTimestamp);
1715
2436
  }
2437
+ candles = remainingCandles;
1716
2438
  }
1717
- // Если достигнут TP/SL, закрываем сигнал
1718
- if (shouldClose) {
1719
- const pnl = toProfitLossDto(signal, averagePrice);
1720
- const closeTimestamp = recentCandles[recentCandles.length - 1].timestamp;
1721
- this.params.logger.debug("ClientStrategy backtest closing", {
1722
- symbol: this.params.execution.context.symbol,
1723
- signalId: signal.id,
1724
- reason: closeReason,
1725
- priceClose: averagePrice,
1726
- closeTimestamp,
1727
- pnlPercentage: pnl.pnlPercentage,
1728
- });
1729
- // Предупреждение при убытке от stop_loss
1730
- if (closeReason === "stop_loss") {
1731
- this.params.logger.warn(`ClientStrategy backtest: Signal closed with loss (stop_loss), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
1732
- }
1733
- if (this.params.callbacks?.onClose) {
1734
- this.params.callbacks.onClose(this.params.execution.context.symbol, signal, averagePrice, this.params.execution.context.backtest);
2439
+ if (this._scheduledSignal) {
2440
+ // Check if timeout reached (CC_SCHEDULE_AWAIT_MINUTES from scheduledAt)
2441
+ const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
2442
+ const lastCandleTimestamp = candles[candles.length - 1].timestamp;
2443
+ const elapsedTime = lastCandleTimestamp - scheduled.scheduledAt;
2444
+ if (elapsedTime < maxTimeToWait) {
2445
+ // Timeout NOT reached yet - signal is still active (waiting for price)
2446
+ // Return active result to continue monitoring in next backtest() call
2447
+ const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2448
+ const lastCandles = candles.slice(-candlesCount);
2449
+ const lastPrice = GET_AVG_PRICE_FN(lastCandles);
2450
+ this.params.logger.debug("ClientStrategy backtest scheduled signal still waiting (not expired)", {
2451
+ symbol: this.params.execution.context.symbol,
2452
+ signalId: scheduled.id,
2453
+ elapsedMinutes: Math.floor(elapsedTime / 60000),
2454
+ maxMinutes: GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES,
2455
+ });
2456
+ // Don't cancel - just return last active state
2457
+ // In real backtest flow this won't happen as we process all candles at once,
2458
+ // but this is correct behavior if someone calls backtest() with partial data
2459
+ const result = {
2460
+ action: "active",
2461
+ signal: scheduled,
2462
+ currentPrice: lastPrice,
2463
+ strategyName: this.params.method.context.strategyName,
2464
+ exchangeName: this.params.method.context.exchangeName,
2465
+ symbol: this.params.execution.context.symbol,
2466
+ };
2467
+ return result; // Cast to IStrategyBacktestResult (which includes Active)
1735
2468
  }
1736
- // Remove signal from risk management
1737
- await this.params.risk.removeSignal(this.params.execution.context.symbol, {
1738
- strategyName: this.params.method.context.strategyName,
1739
- riskName: this.params.riskName,
1740
- });
1741
- await this.setPendingSignal(null);
1742
- const result = {
1743
- action: "closed",
1744
- signal: signal,
1745
- currentPrice: averagePrice,
1746
- closeReason: closeReason,
1747
- closeTimestamp: closeTimestamp,
1748
- pnl: pnl,
1749
- strategyName: this.params.method.context.strategyName,
1750
- exchangeName: this.params.method.context.exchangeName,
2469
+ // Timeout reached - cancel the scheduled signal
2470
+ const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2471
+ const lastCandles = candles.slice(-candlesCount);
2472
+ const lastPrice = GET_AVG_PRICE_FN(lastCandles);
2473
+ this.params.logger.info("ClientStrategy backtest scheduled signal cancelled by timeout", {
1751
2474
  symbol: this.params.execution.context.symbol,
1752
- };
1753
- if (this.params.callbacks?.onTick) {
1754
- this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
1755
- }
1756
- return result;
2475
+ signalId: scheduled.id,
2476
+ closeTimestamp: lastCandleTimestamp,
2477
+ elapsedMinutes: Math.floor(elapsedTime / 60000),
2478
+ maxMinutes: GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES,
2479
+ reason: "timeout - price never reached priceOpen",
2480
+ });
2481
+ return await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(this, scheduled, lastPrice, lastCandleTimestamp);
1757
2482
  }
1758
2483
  }
1759
- // Если TP/SL не достигнут за период, вычисляем VWAP из последних 5 свечей
1760
- const lastFiveCandles = candles.slice(-5);
1761
- const lastPrice = GET_AVG_PRICE_FN(lastFiveCandles);
1762
- const closeTimestamp = lastFiveCandles[lastFiveCandles.length - 1].timestamp;
1763
- const pnl = toProfitLossDto(signal, lastPrice);
1764
- this.params.logger.debug("ClientStrategy backtest time_expired", {
1765
- symbol: this.params.execution.context.symbol,
1766
- signalId: signal.id,
1767
- priceClose: lastPrice,
1768
- closeTimestamp,
1769
- pnlPercentage: pnl.pnlPercentage,
1770
- });
1771
- // Предупреждение при убытке от time_expired
1772
- if (pnl.pnlPercentage < 0) {
1773
- this.params.logger.warn(`ClientStrategy backtest: Signal closed with loss (time_expired), PNL: ${pnl.pnlPercentage.toFixed(2)}%`);
2484
+ // Process pending signal
2485
+ const signal = this._pendingSignal;
2486
+ if (!signal) {
2487
+ throw new Error("ClientStrategy backtest: no pending signal after scheduled activation");
1774
2488
  }
1775
- if (this.params.callbacks?.onClose) {
1776
- this.params.callbacks.onClose(this.params.execution.context.symbol, signal, lastPrice, this.params.execution.context.backtest);
2489
+ const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2490
+ if (candles.length < candlesCount) {
2491
+ this.params.logger.warn(`ClientStrategy backtest: Expected at least ${candlesCount} candles for VWAP, got ${candles.length}`);
1777
2492
  }
1778
- // Remove signal from risk management
1779
- await this.params.risk.removeSignal(this.params.execution.context.symbol, {
1780
- strategyName: this.params.method.context.strategyName,
1781
- riskName: this.params.riskName,
1782
- });
1783
- await this.setPendingSignal(null);
1784
- const result = {
1785
- action: "closed",
1786
- signal: signal,
1787
- currentPrice: lastPrice,
1788
- closeReason: "time_expired",
1789
- closeTimestamp: closeTimestamp,
1790
- pnl: pnl,
1791
- strategyName: this.params.method.context.strategyName,
1792
- exchangeName: this.params.method.context.exchangeName,
1793
- symbol: this.params.execution.context.symbol,
1794
- };
1795
- if (this.params.callbacks?.onTick) {
1796
- this.params.callbacks.onTick(this.params.execution.context.symbol, result, this.params.execution.context.backtest);
2493
+ const closedResult = await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles);
2494
+ if (closedResult) {
2495
+ return closedResult;
1797
2496
  }
1798
- return result;
2497
+ const lastCandles = candles.slice(-GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
2498
+ const lastPrice = GET_AVG_PRICE_FN(lastCandles);
2499
+ const closeTimestamp = lastCandles[lastCandles.length - 1].timestamp;
2500
+ return await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(this, signal, lastPrice, "time_expired", closeTimestamp);
1799
2501
  }
1800
2502
  /**
1801
2503
  * Stops the strategy from generating new signals.
1802
2504
  *
1803
2505
  * Sets internal flag to prevent getSignal from being called.
2506
+ * Clears any scheduled signals (not yet activated).
1804
2507
  * Does NOT close active pending signals - they continue monitoring until TP/SL/time_expired.
1805
2508
  *
1806
2509
  * Use case: Graceful shutdown in live trading without forcing position closure.
@@ -1814,12 +2517,16 @@ class ClientStrategy {
1814
2517
  * // Existing signal will continue until natural close
1815
2518
  * ```
1816
2519
  */
1817
- stop() {
2520
+ async stop() {
1818
2521
  this.params.logger.debug("ClientStrategy stop", {
1819
2522
  hasPendingSignal: this._pendingSignal !== null,
2523
+ hasScheduledSignal: this._scheduledSignal !== null,
1820
2524
  });
1821
2525
  this._isStopped = true;
1822
- return Promise.resolve();
2526
+ // Clear scheduled signal if exists
2527
+ if (this._scheduledSignal) {
2528
+ this._scheduledSignal = null;
2529
+ }
1823
2530
  }
1824
2531
  }
1825
2532
 
@@ -2092,9 +2799,10 @@ class FrameConnectionService {
2092
2799
  * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2093
2800
  * @returns Promise resolving to { startDate: Date, endDate: Date }
2094
2801
  */
2095
- this.getTimeframe = async (symbol) => {
2802
+ this.getTimeframe = async (symbol, frameName) => {
2096
2803
  this.loggerService.log("frameConnectionService getTimeframe", {
2097
2804
  symbol,
2805
+ frameName,
2098
2806
  });
2099
2807
  return await this.getFrame(this.methodContextService.context.frameName).getTimeframe(symbol);
2100
2808
  };
@@ -2575,9 +3283,21 @@ class RiskConnectionService {
2575
3283
  });
2576
3284
  await this.getRisk(context.riskName).removeSignal(symbol, context);
2577
3285
  };
3286
+ /**
3287
+ * Clears the cached ClientRisk instance for the given risk name.
3288
+ *
3289
+ * @param riskName - Name of the risk schema to clear from cache
3290
+ */
3291
+ this.clear = async (riskName) => {
3292
+ this.loggerService.log("riskConnectionService clear", {
3293
+ riskName,
3294
+ });
3295
+ this.getRisk.clear(riskName);
3296
+ };
2578
3297
  }
2579
3298
  }
2580
3299
 
3300
+ const METHOD_NAME_VALIDATE$1 = "exchangeGlobalService validate";
2581
3301
  /**
2582
3302
  * Global service for exchange operations with execution context injection.
2583
3303
  *
@@ -2590,6 +3310,21 @@ class ExchangeGlobalService {
2590
3310
  constructor() {
2591
3311
  this.loggerService = inject(TYPES.loggerService);
2592
3312
  this.exchangeConnectionService = inject(TYPES.exchangeConnectionService);
3313
+ this.methodContextService = inject(TYPES.methodContextService);
3314
+ this.exchangeValidationService = inject(TYPES.exchangeValidationService);
3315
+ /**
3316
+ * Validates exchange configuration.
3317
+ * Memoized to avoid redundant validations for the same exchange.
3318
+ * Logs validation activity.
3319
+ * @param exchangeName - Name of the exchange to validate
3320
+ * @returns Promise that resolves when validation is complete
3321
+ */
3322
+ this.validate = memoize(([exchangeName]) => `${exchangeName}`, async (exchangeName) => {
3323
+ this.loggerService.log(METHOD_NAME_VALIDATE$1, {
3324
+ exchangeName,
3325
+ });
3326
+ this.exchangeValidationService.validate(exchangeName, METHOD_NAME_VALIDATE$1);
3327
+ });
2593
3328
  /**
2594
3329
  * Fetches historical candles with execution context.
2595
3330
  *
@@ -2608,6 +3343,7 @@ class ExchangeGlobalService {
2608
3343
  when,
2609
3344
  backtest,
2610
3345
  });
3346
+ await this.validate(this.methodContextService.context.exchangeName);
2611
3347
  return await ExecutionContextService.runInContext(async () => {
2612
3348
  return await this.exchangeConnectionService.getCandles(symbol, interval, limit);
2613
3349
  }, {
@@ -2634,6 +3370,7 @@ class ExchangeGlobalService {
2634
3370
  when,
2635
3371
  backtest,
2636
3372
  });
3373
+ await this.validate(this.methodContextService.context.exchangeName);
2637
3374
  return await ExecutionContextService.runInContext(async () => {
2638
3375
  return await this.exchangeConnectionService.getNextCandles(symbol, interval, limit);
2639
3376
  }, {
@@ -2656,6 +3393,7 @@ class ExchangeGlobalService {
2656
3393
  when,
2657
3394
  backtest,
2658
3395
  });
3396
+ await this.validate(this.methodContextService.context.exchangeName);
2659
3397
  return await ExecutionContextService.runInContext(async () => {
2660
3398
  return await this.exchangeConnectionService.getAveragePrice(symbol);
2661
3399
  }, {
@@ -2680,6 +3418,7 @@ class ExchangeGlobalService {
2680
3418
  when,
2681
3419
  backtest,
2682
3420
  });
3421
+ await this.validate(this.methodContextService.context.exchangeName);
2683
3422
  return await ExecutionContextService.runInContext(async () => {
2684
3423
  return await this.exchangeConnectionService.formatPrice(symbol, price);
2685
3424
  }, {
@@ -2704,6 +3443,7 @@ class ExchangeGlobalService {
2704
3443
  when,
2705
3444
  backtest,
2706
3445
  });
3446
+ await this.validate(this.methodContextService.context.exchangeName);
2707
3447
  return await ExecutionContextService.runInContext(async () => {
2708
3448
  return await this.exchangeConnectionService.formatQuantity(symbol, quantity);
2709
3449
  }, {
@@ -2715,6 +3455,7 @@ class ExchangeGlobalService {
2715
3455
  }
2716
3456
  }
2717
3457
 
3458
+ const METHOD_NAME_VALIDATE = "strategyGlobalService validate";
2718
3459
  /**
2719
3460
  * Global service for strategy operations with execution context injection.
2720
3461
  *
@@ -2727,6 +3468,28 @@ class StrategyGlobalService {
2727
3468
  constructor() {
2728
3469
  this.loggerService = inject(TYPES.loggerService);
2729
3470
  this.strategyConnectionService = inject(TYPES.strategyConnectionService);
3471
+ this.strategySchemaService = inject(TYPES.strategySchemaService);
3472
+ this.riskValidationService = inject(TYPES.riskValidationService);
3473
+ this.strategyValidationService = inject(TYPES.strategyValidationService);
3474
+ this.methodContextService = inject(TYPES.methodContextService);
3475
+ /**
3476
+ * Validates strategy and associated risk configuration.
3477
+ *
3478
+ * Memoized to avoid redundant validations for the same strategy.
3479
+ * Logs validation activity.
3480
+ * @param strategyName - Name of the strategy to validate
3481
+ * @returns Promise that resolves when validation is complete
3482
+ */
3483
+ this.validate = memoize(([strategyName]) => `${strategyName}`, async (strategyName) => {
3484
+ this.loggerService.log(METHOD_NAME_VALIDATE, {
3485
+ strategyName,
3486
+ });
3487
+ const strategySchema = this.strategySchemaService.get(strategyName);
3488
+ this.strategyValidationService.validate(strategyName, METHOD_NAME_VALIDATE);
3489
+ const riskName = strategySchema.riskName;
3490
+ riskName &&
3491
+ this.riskValidationService.validate(riskName, METHOD_NAME_VALIDATE);
3492
+ });
2730
3493
  /**
2731
3494
  * Checks signal status at a specific timestamp.
2732
3495
  *
@@ -2744,6 +3507,7 @@ class StrategyGlobalService {
2744
3507
  when,
2745
3508
  backtest,
2746
3509
  });
3510
+ await this.validate(this.methodContextService.context.strategyName);
2747
3511
  return await ExecutionContextService.runInContext(async () => {
2748
3512
  return await this.strategyConnectionService.tick();
2749
3513
  }, {
@@ -2771,6 +3535,7 @@ class StrategyGlobalService {
2771
3535
  when,
2772
3536
  backtest,
2773
3537
  });
3538
+ await this.validate(this.methodContextService.context.strategyName);
2774
3539
  return await ExecutionContextService.runInContext(async () => {
2775
3540
  return await this.strategyConnectionService.backtest(candles);
2776
3541
  }, {
@@ -2792,6 +3557,7 @@ class StrategyGlobalService {
2792
3557
  this.loggerService.log("strategyGlobalService stop", {
2793
3558
  strategyName,
2794
3559
  });
3560
+ await this.validate(strategyName);
2795
3561
  return await this.strategyConnectionService.stop(strategyName);
2796
3562
  };
2797
3563
  /**
@@ -2806,11 +3572,15 @@ class StrategyGlobalService {
2806
3572
  this.loggerService.log("strategyGlobalService clear", {
2807
3573
  strategyName,
2808
3574
  });
3575
+ if (strategyName) {
3576
+ await this.validate(strategyName);
3577
+ }
2809
3578
  return await this.strategyConnectionService.clear(strategyName);
2810
3579
  };
2811
3580
  }
2812
3581
  }
2813
3582
 
3583
+ const METHOD_NAME_GET_TIMEFRAME = "frameGlobalService getTimeframe";
2814
3584
  /**
2815
3585
  * Global service for frame operations.
2816
3586
  *
@@ -2821,21 +3591,25 @@ class FrameGlobalService {
2821
3591
  constructor() {
2822
3592
  this.loggerService = inject(TYPES.loggerService);
2823
3593
  this.frameConnectionService = inject(TYPES.frameConnectionService);
3594
+ this.frameValidationService = inject(TYPES.frameValidationService);
2824
3595
  /**
2825
3596
  * Generates timeframe array for backtest iteration.
2826
3597
  *
2827
- * @param symbol - Trading pair symbol
3598
+ * @param frameName - Target frame name (e.g., "1m", "1h")
2828
3599
  * @returns Promise resolving to array of Date objects
2829
3600
  */
2830
- this.getTimeframe = async (symbol) => {
2831
- this.loggerService.log("frameGlobalService getTimeframe", {
3601
+ this.getTimeframe = async (symbol, frameName) => {
3602
+ this.loggerService.log(METHOD_NAME_GET_TIMEFRAME, {
3603
+ frameName,
2832
3604
  symbol,
2833
3605
  });
2834
- return await this.frameConnectionService.getTimeframe(symbol);
3606
+ this.frameValidationService.validate(frameName, METHOD_NAME_GET_TIMEFRAME);
3607
+ return await this.frameConnectionService.getTimeframe(symbol, frameName);
2835
3608
  };
2836
3609
  }
2837
3610
  }
2838
3611
 
3612
+ const METHOD_NAME_CALCULATE = "sizingGlobalService calculate";
2839
3613
  /**
2840
3614
  * Global service for sizing operations.
2841
3615
  *
@@ -2846,6 +3620,7 @@ class SizingGlobalService {
2846
3620
  constructor() {
2847
3621
  this.loggerService = inject(TYPES.loggerService);
2848
3622
  this.sizingConnectionService = inject(TYPES.sizingConnectionService);
3623
+ this.sizingValidationService = inject(TYPES.sizingValidationService);
2849
3624
  /**
2850
3625
  * Calculates position size based on risk parameters.
2851
3626
  *
@@ -2854,11 +3629,12 @@ class SizingGlobalService {
2854
3629
  * @returns Promise resolving to calculated position size
2855
3630
  */
2856
3631
  this.calculate = async (params, context) => {
2857
- this.loggerService.log("sizingGlobalService calculate", {
3632
+ this.loggerService.log(METHOD_NAME_CALCULATE, {
2858
3633
  symbol: params.symbol,
2859
3634
  method: params.method,
2860
3635
  context,
2861
3636
  });
3637
+ this.sizingValidationService.validate(context.sizingName, METHOD_NAME_CALCULATE);
2862
3638
  return await this.sizingConnectionService.calculate(params, context);
2863
3639
  };
2864
3640
  }
@@ -2874,6 +3650,20 @@ class RiskGlobalService {
2874
3650
  constructor() {
2875
3651
  this.loggerService = inject(TYPES.loggerService);
2876
3652
  this.riskConnectionService = inject(TYPES.riskConnectionService);
3653
+ this.riskValidationService = inject(TYPES.riskValidationService);
3654
+ /**
3655
+ * Validates risk configuration.
3656
+ * Memoized to avoid redundant validations for the same risk instance.
3657
+ * Logs validation activity.
3658
+ * @param riskName - Name of the risk instance to validate
3659
+ * @returns Promise that resolves when validation is complete
3660
+ */
3661
+ this.validate = memoize(([riskName]) => `${riskName}`, async (riskName) => {
3662
+ this.loggerService.log("riskGlobalService validate", {
3663
+ riskName,
3664
+ });
3665
+ this.riskValidationService.validate(riskName, "riskGlobalService validate");
3666
+ });
2877
3667
  /**
2878
3668
  * Checks if a signal should be allowed based on risk limits.
2879
3669
  *
@@ -2886,6 +3676,7 @@ class RiskGlobalService {
2886
3676
  symbol: params.symbol,
2887
3677
  context,
2888
3678
  });
3679
+ await this.validate(context.riskName);
2889
3680
  return await this.riskConnectionService.checkSignal(params, context);
2890
3681
  };
2891
3682
  /**
@@ -2899,6 +3690,7 @@ class RiskGlobalService {
2899
3690
  symbol,
2900
3691
  context,
2901
3692
  });
3693
+ await this.validate(context.riskName);
2902
3694
  await this.riskConnectionService.addSignal(symbol, context);
2903
3695
  };
2904
3696
  /**
@@ -2912,8 +3704,24 @@ class RiskGlobalService {
2912
3704
  symbol,
2913
3705
  context,
2914
3706
  });
3707
+ await this.validate(context.riskName);
2915
3708
  await this.riskConnectionService.removeSignal(symbol, context);
2916
3709
  };
3710
+ /**
3711
+ * Clears risk data.
3712
+ * If riskName is provided, clears data for that specific risk instance.
3713
+ * If no riskName is provided, clears all risk data.
3714
+ * @param riskName - Optional name of the risk instance to clear
3715
+ */
3716
+ this.clear = async (riskName) => {
3717
+ this.loggerService.log("riskGlobalService clear", {
3718
+ riskName,
3719
+ });
3720
+ if (riskName) {
3721
+ await this.validate(riskName);
3722
+ }
3723
+ return await this.riskConnectionService.clear(riskName);
3724
+ };
2917
3725
  }
2918
3726
  }
2919
3727
 
@@ -3424,7 +4232,7 @@ class BacktestLogicPrivateService {
3424
4232
  symbol,
3425
4233
  });
3426
4234
  const backtestStartTime = performance.now();
3427
- const timeframes = await this.frameGlobalService.getTimeframe(symbol);
4235
+ const timeframes = await this.frameGlobalService.getTimeframe(symbol, this.methodContextService.context.frameName);
3428
4236
  const totalFrames = timeframes.length;
3429
4237
  let i = 0;
3430
4238
  let previousEventTimestamp = null;
@@ -3443,7 +4251,66 @@ class BacktestLogicPrivateService {
3443
4251
  });
3444
4252
  }
3445
4253
  const result = await this.strategyGlobalService.tick(symbol, when, true);
3446
- // Если сигнал открыт, вызываем backtest
4254
+ // Если scheduled signal создан - обрабатываем через backtest()
4255
+ if (result.action === "scheduled") {
4256
+ const signalStartTime = performance.now();
4257
+ const signal = result.signal;
4258
+ this.loggerService.info("backtestLogicPrivateService scheduled signal detected", {
4259
+ symbol,
4260
+ signalId: signal.id,
4261
+ priceOpen: signal.priceOpen,
4262
+ minuteEstimatedTime: signal.minuteEstimatedTime,
4263
+ });
4264
+ // Запрашиваем минутные свечи для мониторинга активации/отмены
4265
+ // КРИТИЧНО: запрашиваем CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
4266
+ // + minuteEstimatedTime для работы сигнала ПОСЛЕ активации
4267
+ // +1 потому что when включается как первая свеча (timestamp начинается с when, а не after when)
4268
+ const candlesNeeded = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
4269
+ const candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, when, true);
4270
+ if (!candles.length) {
4271
+ i++;
4272
+ continue;
4273
+ }
4274
+ this.loggerService.info("backtestLogicPrivateService candles fetched for scheduled", {
4275
+ symbol,
4276
+ signalId: signal.id,
4277
+ candlesCount: candles.length,
4278
+ candlesNeeded,
4279
+ });
4280
+ // backtest() сам обработает scheduled signal: найдет активацию/отмену
4281
+ // и если активируется - продолжит с TP/SL мониторингом
4282
+ const backtestResult = await this.strategyGlobalService.backtest(symbol, candles, when, true);
4283
+ this.loggerService.info("backtestLogicPrivateService scheduled signal closed", {
4284
+ symbol,
4285
+ signalId: backtestResult.signal.id,
4286
+ closeTimestamp: backtestResult.closeTimestamp,
4287
+ action: backtestResult.action,
4288
+ closeReason: backtestResult.action === "closed"
4289
+ ? backtestResult.closeReason
4290
+ : undefined,
4291
+ });
4292
+ // Track signal processing duration
4293
+ const signalEndTime = performance.now();
4294
+ const currentTimestamp = Date.now();
4295
+ await performanceEmitter.next({
4296
+ timestamp: currentTimestamp,
4297
+ previousTimestamp: previousEventTimestamp,
4298
+ metricType: "backtest_signal",
4299
+ duration: signalEndTime - signalStartTime,
4300
+ strategyName: this.methodContextService.context.strategyName,
4301
+ exchangeName: this.methodContextService.context.exchangeName,
4302
+ symbol,
4303
+ backtest: true,
4304
+ });
4305
+ previousEventTimestamp = currentTimestamp;
4306
+ // Пропускаем timeframes до closeTimestamp
4307
+ while (i < timeframes.length &&
4308
+ timeframes[i].getTime() < backtestResult.closeTimestamp) {
4309
+ i++;
4310
+ }
4311
+ yield backtestResult;
4312
+ }
4313
+ // Если обычный сигнал открыт, вызываем backtest
3447
4314
  if (result.action === "opened") {
3448
4315
  const signalStartTime = performance.now();
3449
4316
  const signal = result.signal;
@@ -3468,7 +4335,6 @@ class BacktestLogicPrivateService {
3468
4335
  symbol,
3469
4336
  signalId: backtestResult.signal.id,
3470
4337
  closeTimestamp: backtestResult.closeTimestamp,
3471
- closeReason: backtestResult.closeReason,
3472
4338
  });
3473
4339
  // Track signal processing duration
3474
4340
  const signalEndTime = performance.now();
@@ -3615,6 +4481,11 @@ class LiveLogicPrivateService {
3615
4481
  await sleep(TICK_TTL);
3616
4482
  continue;
3617
4483
  }
4484
+ if (result.action === "scheduled") {
4485
+ await sleep(TICK_TTL);
4486
+ continue;
4487
+ }
4488
+ // Yield opened, closed, cancelled results
3618
4489
  yield result;
3619
4490
  await sleep(TICK_TTL);
3620
4491
  }
@@ -3935,6 +4806,8 @@ class LiveGlobalService {
3935
4806
  this.liveLogicPublicService = inject(TYPES.liveLogicPublicService);
3936
4807
  this.strategyValidationService = inject(TYPES.strategyValidationService);
3937
4808
  this.exchangeValidationService = inject(TYPES.exchangeValidationService);
4809
+ this.strategySchemaService = inject(TYPES.strategySchemaService);
4810
+ this.riskValidationService = inject(TYPES.riskValidationService);
3938
4811
  /**
3939
4812
  * Runs live trading for a symbol with context propagation.
3940
4813
  *
@@ -3949,8 +4822,16 @@ class LiveGlobalService {
3949
4822
  symbol,
3950
4823
  context,
3951
4824
  });
3952
- this.strategyValidationService.validate(context.strategyName, METHOD_NAME_RUN$2);
3953
- this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN$2);
4825
+ {
4826
+ this.strategyValidationService.validate(context.strategyName, METHOD_NAME_RUN$2);
4827
+ this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN$2);
4828
+ }
4829
+ {
4830
+ const strategySchema = this.strategySchemaService.get(context.strategyName);
4831
+ const riskName = strategySchema.riskName;
4832
+ riskName &&
4833
+ this.riskValidationService.validate(riskName, METHOD_NAME_RUN$2);
4834
+ }
3954
4835
  return this.liveLogicPublicService.run(symbol, context);
3955
4836
  };
3956
4837
  }
@@ -3966,6 +4847,8 @@ const METHOD_NAME_RUN$1 = "backtestGlobalService run";
3966
4847
  class BacktestGlobalService {
3967
4848
  constructor() {
3968
4849
  this.loggerService = inject(TYPES.loggerService);
4850
+ this.strategySchemaService = inject(TYPES.strategySchemaService);
4851
+ this.riskValidationService = inject(TYPES.riskValidationService);
3969
4852
  this.backtestLogicPublicService = inject(TYPES.backtestLogicPublicService);
3970
4853
  this.strategyValidationService = inject(TYPES.strategyValidationService);
3971
4854
  this.exchangeValidationService = inject(TYPES.exchangeValidationService);
@@ -3982,9 +4865,16 @@ class BacktestGlobalService {
3982
4865
  symbol,
3983
4866
  context,
3984
4867
  });
3985
- this.strategyValidationService.validate(context.strategyName, METHOD_NAME_RUN$1);
3986
- this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN$1);
3987
- this.frameValidationService.validate(context.frameName, METHOD_NAME_RUN$1);
4868
+ {
4869
+ this.strategyValidationService.validate(context.strategyName, METHOD_NAME_RUN$1);
4870
+ this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN$1);
4871
+ this.frameValidationService.validate(context.frameName, METHOD_NAME_RUN$1);
4872
+ }
4873
+ {
4874
+ const strategySchema = this.strategySchemaService.get(context.strategyName);
4875
+ const riskName = strategySchema.riskName;
4876
+ riskName && this.riskValidationService.validate(riskName, METHOD_NAME_RUN$1);
4877
+ }
3988
4878
  return this.backtestLogicPublicService.run(symbol, context);
3989
4879
  };
3990
4880
  }
@@ -4001,6 +4891,13 @@ class WalkerGlobalService {
4001
4891
  constructor() {
4002
4892
  this.loggerService = inject(TYPES.loggerService);
4003
4893
  this.walkerLogicPublicService = inject(TYPES.walkerLogicPublicService);
4894
+ this.walkerSchemaService = inject(TYPES.walkerSchemaService);
4895
+ this.strategyValidationService = inject(TYPES.strategyValidationService);
4896
+ this.exchangeValidationService = inject(TYPES.exchangeValidationService);
4897
+ this.frameValidationService = inject(TYPES.frameValidationService);
4898
+ this.walkerValidationService = inject(TYPES.walkerValidationService);
4899
+ this.strategySchemaService = inject(TYPES.strategySchemaService);
4900
+ this.riskValidationService = inject(TYPES.riskValidationService);
4004
4901
  /**
4005
4902
  * Runs walker comparison for a symbol with context propagation.
4006
4903
  *
@@ -4012,6 +4909,21 @@ class WalkerGlobalService {
4012
4909
  symbol,
4013
4910
  context,
4014
4911
  });
4912
+ {
4913
+ this.exchangeValidationService.validate(context.exchangeName, METHOD_NAME_RUN);
4914
+ this.frameValidationService.validate(context.frameName, METHOD_NAME_RUN);
4915
+ this.walkerValidationService.validate(context.walkerName, METHOD_NAME_RUN);
4916
+ }
4917
+ {
4918
+ const walkerSchema = this.walkerSchemaService.get(context.walkerName);
4919
+ for (const strategyName of walkerSchema.strategies) {
4920
+ const strategySchema = this.strategySchemaService.get(strategyName);
4921
+ this.strategyValidationService.validate(strategyName, METHOD_NAME_RUN);
4922
+ const riskName = strategySchema.riskName;
4923
+ riskName &&
4924
+ this.riskValidationService.validate(riskName, METHOD_NAME_RUN);
4925
+ }
4926
+ }
4015
4927
  return this.walkerLogicPublicService.run(symbol, context);
4016
4928
  };
4017
4929
  }
@@ -4035,7 +4947,7 @@ function isUnsafe$3(value) {
4035
4947
  }
4036
4948
  return false;
4037
4949
  }
4038
- const columns$2 = [
4950
+ const columns$3 = [
4039
4951
  {
4040
4952
  key: "signalId",
4041
4953
  label: "Signal ID",
@@ -4093,7 +5005,7 @@ const columns$2 = [
4093
5005
  key: "duration",
4094
5006
  label: "Duration (min)",
4095
5007
  format: (data) => {
4096
- const durationMs = data.closeTimestamp - data.signal.timestamp;
5008
+ const durationMs = data.closeTimestamp - data.signal.pendingAt;
4097
5009
  const durationMin = Math.round(durationMs / 60000);
4098
5010
  return `${durationMin}`;
4099
5011
  },
@@ -4101,7 +5013,7 @@ const columns$2 = [
4101
5013
  {
4102
5014
  key: "openTimestamp",
4103
5015
  label: "Open Time",
4104
- format: (data) => new Date(data.signal.timestamp).toISOString(),
5016
+ format: (data) => new Date(data.signal.pendingAt).toISOString(),
4105
5017
  },
4106
5018
  {
4107
5019
  key: "closeTimestamp",
@@ -4113,7 +5025,7 @@ const columns$2 = [
4113
5025
  * Storage class for accumulating closed signals per strategy.
4114
5026
  * Maintains a list of all closed signals and provides methods to generate reports.
4115
5027
  */
4116
- let ReportStorage$2 = class ReportStorage {
5028
+ let ReportStorage$3 = class ReportStorage {
4117
5029
  constructor() {
4118
5030
  /** Internal list of all closed signals for this strategy */
4119
5031
  this._signalList = [];
@@ -4173,7 +5085,7 @@ let ReportStorage$2 = class ReportStorage {
4173
5085
  : 0;
4174
5086
  const certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
4175
5087
  // Calculate Expected Yearly Returns
4176
- const avgDurationMs = this._signalList.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.timestamp), 0) / totalSignals;
5088
+ const avgDurationMs = this._signalList.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / totalSignals;
4177
5089
  const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
4178
5090
  const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
4179
5091
  const expectedYearlyReturns = avgPnl * tradesPerYear;
@@ -4203,9 +5115,9 @@ let ReportStorage$2 = class ReportStorage {
4203
5115
  if (stats.totalSignals === 0) {
4204
5116
  return str.newline(`# Backtest Report: ${strategyName}`, "", "No signals closed yet.");
4205
5117
  }
4206
- const header = columns$2.map((col) => col.label);
4207
- const separator = columns$2.map(() => "---");
4208
- const rows = this._signalList.map((closedSignal) => columns$2.map((col) => col.format(closedSignal)));
5118
+ const header = columns$3.map((col) => col.label);
5119
+ const separator = columns$3.map(() => "---");
5120
+ const rows = this._signalList.map((closedSignal) => columns$3.map((col) => col.format(closedSignal)));
4209
5121
  const tableData = [header, separator, ...rows];
4210
5122
  const table = str.newline(tableData.map(row => `| ${row.join(" | ")} |`));
4211
5123
  return str.newline(`# Backtest Report: ${strategyName}`, "", table, "", `**Total signals:** ${stats.totalSignals}`, `**Closed signals:** ${stats.totalSignals}`, `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`, `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`, `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`, `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`, `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`, `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`, `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`, `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`);
@@ -4266,7 +5178,7 @@ class BacktestMarkdownService {
4266
5178
  * Memoized function to get or create ReportStorage for a strategy.
4267
5179
  * Each strategy gets its own isolated storage instance.
4268
5180
  */
4269
- this.getStorage = memoize(([strategyName]) => `${strategyName}`, () => new ReportStorage$2());
5181
+ this.getStorage = memoize(([strategyName]) => `${strategyName}`, () => new ReportStorage$3());
4270
5182
  /**
4271
5183
  * Processes tick events and accumulates closed signals.
4272
5184
  * Should be called from IStrategyCallbacks.onTick.
@@ -4425,7 +5337,7 @@ function isUnsafe$2(value) {
4425
5337
  }
4426
5338
  return false;
4427
5339
  }
4428
- const columns$1 = [
5340
+ const columns$2 = [
4429
5341
  {
4430
5342
  key: "timestamp",
4431
5343
  label: "Timestamp",
@@ -4499,12 +5411,12 @@ const columns$1 = [
4499
5411
  },
4500
5412
  ];
4501
5413
  /** Maximum number of events to store in live trading reports */
4502
- const MAX_EVENTS$1 = 250;
5414
+ const MAX_EVENTS$2 = 250;
4503
5415
  /**
4504
5416
  * Storage class for accumulating all tick events per strategy.
4505
5417
  * Maintains a chronological list of all events (idle, opened, active, closed).
4506
5418
  */
4507
- let ReportStorage$1 = class ReportStorage {
5419
+ let ReportStorage$2 = class ReportStorage {
4508
5420
  constructor() {
4509
5421
  /** Internal list of all tick events for this strategy */
4510
5422
  this._eventList = [];
@@ -4532,7 +5444,7 @@ let ReportStorage$1 = class ReportStorage {
4532
5444
  }
4533
5445
  {
4534
5446
  this._eventList.push(newEvent);
4535
- if (this._eventList.length > MAX_EVENTS$1) {
5447
+ if (this._eventList.length > MAX_EVENTS$2) {
4536
5448
  this._eventList.shift();
4537
5449
  }
4538
5450
  }
@@ -4544,7 +5456,7 @@ let ReportStorage$1 = class ReportStorage {
4544
5456
  */
4545
5457
  addOpenedEvent(data) {
4546
5458
  this._eventList.push({
4547
- timestamp: data.signal.timestamp,
5459
+ timestamp: data.signal.pendingAt,
4548
5460
  action: "opened",
4549
5461
  symbol: data.signal.symbol,
4550
5462
  signalId: data.signal.id,
@@ -4556,7 +5468,7 @@ let ReportStorage$1 = class ReportStorage {
4556
5468
  stopLoss: data.signal.priceStopLoss,
4557
5469
  });
4558
5470
  // Trim queue if exceeded MAX_EVENTS
4559
- if (this._eventList.length > MAX_EVENTS$1) {
5471
+ if (this._eventList.length > MAX_EVENTS$2) {
4560
5472
  this._eventList.shift();
4561
5473
  }
4562
5474
  }
@@ -4588,35 +5500,472 @@ let ReportStorage$1 = class ReportStorage {
4588
5500
  else {
4589
5501
  this._eventList.push(newEvent);
4590
5502
  // Trim queue if exceeded MAX_EVENTS
4591
- if (this._eventList.length > MAX_EVENTS$1) {
5503
+ if (this._eventList.length > MAX_EVENTS$2) {
5504
+ this._eventList.shift();
5505
+ }
5506
+ }
5507
+ }
5508
+ /**
5509
+ * Updates or adds a closed event to the storage.
5510
+ * Replaces the previous event with the same signalId.
5511
+ *
5512
+ * @param data - Closed tick result
5513
+ */
5514
+ addClosedEvent(data) {
5515
+ const durationMs = data.closeTimestamp - data.signal.pendingAt;
5516
+ const durationMin = Math.round(durationMs / 60000);
5517
+ // Find existing event with the same signalId
5518
+ const existingIndex = this._eventList.findIndex((event) => event.signalId === data.signal.id);
5519
+ const newEvent = {
5520
+ timestamp: data.closeTimestamp,
5521
+ action: "closed",
5522
+ symbol: data.signal.symbol,
5523
+ signalId: data.signal.id,
5524
+ position: data.signal.position,
5525
+ note: data.signal.note,
5526
+ currentPrice: data.currentPrice,
5527
+ openPrice: data.signal.priceOpen,
5528
+ takeProfit: data.signal.priceTakeProfit,
5529
+ stopLoss: data.signal.priceStopLoss,
5530
+ pnl: data.pnl.pnlPercentage,
5531
+ closeReason: data.closeReason,
5532
+ duration: durationMin,
5533
+ };
5534
+ // Replace existing event or add new one
5535
+ if (existingIndex !== -1) {
5536
+ this._eventList[existingIndex] = newEvent;
5537
+ }
5538
+ else {
5539
+ this._eventList.push(newEvent);
5540
+ // Trim queue if exceeded MAX_EVENTS
5541
+ if (this._eventList.length > MAX_EVENTS$2) {
4592
5542
  this._eventList.shift();
4593
5543
  }
4594
5544
  }
4595
5545
  }
4596
5546
  /**
4597
- * Updates or adds a closed event to the storage.
5547
+ * Calculates statistical data from live trading events (Controller).
5548
+ * Returns null for any unsafe numeric values (NaN, Infinity, etc).
5549
+ *
5550
+ * @returns Statistical data (empty object if no events)
5551
+ */
5552
+ async getData() {
5553
+ if (this._eventList.length === 0) {
5554
+ return {
5555
+ eventList: [],
5556
+ totalEvents: 0,
5557
+ totalClosed: 0,
5558
+ winCount: 0,
5559
+ lossCount: 0,
5560
+ winRate: null,
5561
+ avgPnl: null,
5562
+ totalPnl: null,
5563
+ stdDev: null,
5564
+ sharpeRatio: null,
5565
+ annualizedSharpeRatio: null,
5566
+ certaintyRatio: null,
5567
+ expectedYearlyReturns: null,
5568
+ };
5569
+ }
5570
+ const closedEvents = this._eventList.filter((e) => e.action === "closed");
5571
+ const totalClosed = closedEvents.length;
5572
+ const winCount = closedEvents.filter((e) => e.pnl && e.pnl > 0).length;
5573
+ const lossCount = closedEvents.filter((e) => e.pnl && e.pnl < 0).length;
5574
+ // Calculate basic statistics
5575
+ const avgPnl = totalClosed > 0
5576
+ ? closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0) / totalClosed
5577
+ : 0;
5578
+ const totalPnl = closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0);
5579
+ const winRate = (winCount / totalClosed) * 100;
5580
+ // Calculate Sharpe Ratio (risk-free rate = 0)
5581
+ let sharpeRatio = 0;
5582
+ let stdDev = 0;
5583
+ if (totalClosed > 0) {
5584
+ const returns = closedEvents.map((e) => e.pnl || 0);
5585
+ const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / totalClosed;
5586
+ stdDev = Math.sqrt(variance);
5587
+ sharpeRatio = stdDev > 0 ? avgPnl / stdDev : 0;
5588
+ }
5589
+ const annualizedSharpeRatio = sharpeRatio * Math.sqrt(365);
5590
+ // Calculate Certainty Ratio
5591
+ let certaintyRatio = 0;
5592
+ if (totalClosed > 0) {
5593
+ const wins = closedEvents.filter((e) => e.pnl && e.pnl > 0);
5594
+ const losses = closedEvents.filter((e) => e.pnl && e.pnl < 0);
5595
+ const avgWin = wins.length > 0
5596
+ ? wins.reduce((sum, e) => sum + (e.pnl || 0), 0) / wins.length
5597
+ : 0;
5598
+ const avgLoss = losses.length > 0
5599
+ ? losses.reduce((sum, e) => sum + (e.pnl || 0), 0) / losses.length
5600
+ : 0;
5601
+ certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
5602
+ }
5603
+ // Calculate Expected Yearly Returns
5604
+ let expectedYearlyReturns = 0;
5605
+ if (totalClosed > 0) {
5606
+ const avgDurationMin = closedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) / totalClosed;
5607
+ const avgDurationDays = avgDurationMin / (60 * 24);
5608
+ const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
5609
+ expectedYearlyReturns = avgPnl * tradesPerYear;
5610
+ }
5611
+ return {
5612
+ eventList: this._eventList,
5613
+ totalEvents: this._eventList.length,
5614
+ totalClosed,
5615
+ winCount,
5616
+ lossCount,
5617
+ winRate: isUnsafe$2(winRate) ? null : winRate,
5618
+ avgPnl: isUnsafe$2(avgPnl) ? null : avgPnl,
5619
+ totalPnl: isUnsafe$2(totalPnl) ? null : totalPnl,
5620
+ stdDev: isUnsafe$2(stdDev) ? null : stdDev,
5621
+ sharpeRatio: isUnsafe$2(sharpeRatio) ? null : sharpeRatio,
5622
+ annualizedSharpeRatio: isUnsafe$2(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
5623
+ certaintyRatio: isUnsafe$2(certaintyRatio) ? null : certaintyRatio,
5624
+ expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
5625
+ };
5626
+ }
5627
+ /**
5628
+ * Generates markdown report with all tick events for a strategy (View).
5629
+ *
5630
+ * @param strategyName - Strategy name
5631
+ * @returns Markdown formatted report with all events
5632
+ */
5633
+ async getReport(strategyName) {
5634
+ const stats = await this.getData();
5635
+ if (stats.totalEvents === 0) {
5636
+ return str.newline(`# Live Trading Report: ${strategyName}`, "", "No events recorded yet.");
5637
+ }
5638
+ const header = columns$2.map((col) => col.label);
5639
+ const separator = columns$2.map(() => "---");
5640
+ const rows = this._eventList.map((event) => columns$2.map((col) => col.format(event)));
5641
+ const tableData = [header, separator, ...rows];
5642
+ const table = str.newline(tableData.map(row => `| ${row.join(" | ")} |`));
5643
+ return str.newline(`# Live Trading Report: ${strategyName}`, "", table, "", `**Total events:** ${stats.totalEvents}`, `**Closed signals:** ${stats.totalClosed}`, `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`, `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`, `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`, `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`, `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`, `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`, `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`, `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`);
5644
+ }
5645
+ /**
5646
+ * Saves strategy report to disk.
5647
+ *
5648
+ * @param strategyName - Strategy name
5649
+ * @param path - Directory path to save report (default: "./logs/live")
5650
+ */
5651
+ async dump(strategyName, path = "./logs/live") {
5652
+ const markdown = await this.getReport(strategyName);
5653
+ try {
5654
+ const dir = join(process.cwd(), path);
5655
+ await mkdir(dir, { recursive: true });
5656
+ const filename = `${strategyName}.md`;
5657
+ const filepath = join(dir, filename);
5658
+ await writeFile(filepath, markdown, "utf-8");
5659
+ console.log(`Live trading report saved: ${filepath}`);
5660
+ }
5661
+ catch (error) {
5662
+ console.error(`Failed to save markdown report:`, error);
5663
+ }
5664
+ }
5665
+ };
5666
+ /**
5667
+ * Service for generating and saving live trading markdown reports.
5668
+ *
5669
+ * Features:
5670
+ * - Listens to all signal events via onTick callback
5671
+ * - Accumulates all events (idle, opened, active, closed) per strategy
5672
+ * - Generates markdown tables with detailed event information
5673
+ * - Provides trading statistics (win rate, average PNL)
5674
+ * - Saves reports to disk in logs/live/{strategyName}.md
5675
+ *
5676
+ * @example
5677
+ * ```typescript
5678
+ * const service = new LiveMarkdownService();
5679
+ *
5680
+ * // Add to strategy callbacks
5681
+ * addStrategy({
5682
+ * strategyName: "my-strategy",
5683
+ * callbacks: {
5684
+ * onTick: (symbol, result, backtest) => {
5685
+ * if (!backtest) {
5686
+ * service.tick(result);
5687
+ * }
5688
+ * }
5689
+ * }
5690
+ * });
5691
+ *
5692
+ * // Later: generate and save report
5693
+ * await service.dump("my-strategy");
5694
+ * ```
5695
+ */
5696
+ class LiveMarkdownService {
5697
+ constructor() {
5698
+ /** Logger service for debug output */
5699
+ this.loggerService = inject(TYPES.loggerService);
5700
+ /**
5701
+ * Memoized function to get or create ReportStorage for a strategy.
5702
+ * Each strategy gets its own isolated storage instance.
5703
+ */
5704
+ this.getStorage = memoize(([strategyName]) => `${strategyName}`, () => new ReportStorage$2());
5705
+ /**
5706
+ * Processes tick events and accumulates all event types.
5707
+ * Should be called from IStrategyCallbacks.onTick.
5708
+ *
5709
+ * Processes all event types: idle, opened, active, closed.
5710
+ *
5711
+ * @param data - Tick result from strategy execution
5712
+ *
5713
+ * @example
5714
+ * ```typescript
5715
+ * const service = new LiveMarkdownService();
5716
+ *
5717
+ * callbacks: {
5718
+ * onTick: (symbol, result, backtest) => {
5719
+ * if (!backtest) {
5720
+ * service.tick(result);
5721
+ * }
5722
+ * }
5723
+ * }
5724
+ * ```
5725
+ */
5726
+ this.tick = async (data) => {
5727
+ this.loggerService.log("liveMarkdownService tick", {
5728
+ data,
5729
+ });
5730
+ const storage = this.getStorage(data.strategyName);
5731
+ if (data.action === "idle") {
5732
+ storage.addIdleEvent(data.currentPrice);
5733
+ }
5734
+ else if (data.action === "opened") {
5735
+ storage.addOpenedEvent(data);
5736
+ }
5737
+ else if (data.action === "active") {
5738
+ storage.addActiveEvent(data);
5739
+ }
5740
+ else if (data.action === "closed") {
5741
+ storage.addClosedEvent(data);
5742
+ }
5743
+ };
5744
+ /**
5745
+ * Gets statistical data from all live trading events for a strategy.
5746
+ * Delegates to ReportStorage.getData().
5747
+ *
5748
+ * @param strategyName - Strategy name to get data for
5749
+ * @returns Statistical data object with all metrics
5750
+ *
5751
+ * @example
5752
+ * ```typescript
5753
+ * const service = new LiveMarkdownService();
5754
+ * const stats = await service.getData("my-strategy");
5755
+ * console.log(stats.sharpeRatio, stats.winRate);
5756
+ * ```
5757
+ */
5758
+ this.getData = async (strategyName) => {
5759
+ this.loggerService.log("liveMarkdownService getData", {
5760
+ strategyName,
5761
+ });
5762
+ const storage = this.getStorage(strategyName);
5763
+ return storage.getData();
5764
+ };
5765
+ /**
5766
+ * Generates markdown report with all events for a strategy.
5767
+ * Delegates to ReportStorage.getReport().
5768
+ *
5769
+ * @param strategyName - Strategy name to generate report for
5770
+ * @returns Markdown formatted report string with table of all events
5771
+ *
5772
+ * @example
5773
+ * ```typescript
5774
+ * const service = new LiveMarkdownService();
5775
+ * const markdown = await service.getReport("my-strategy");
5776
+ * console.log(markdown);
5777
+ * ```
5778
+ */
5779
+ this.getReport = async (strategyName) => {
5780
+ this.loggerService.log("liveMarkdownService getReport", {
5781
+ strategyName,
5782
+ });
5783
+ const storage = this.getStorage(strategyName);
5784
+ return storage.getReport(strategyName);
5785
+ };
5786
+ /**
5787
+ * Saves strategy report to disk.
5788
+ * Creates directory if it doesn't exist.
5789
+ * Delegates to ReportStorage.dump().
5790
+ *
5791
+ * @param strategyName - Strategy name to save report for
5792
+ * @param path - Directory path to save report (default: "./logs/live")
5793
+ *
5794
+ * @example
5795
+ * ```typescript
5796
+ * const service = new LiveMarkdownService();
5797
+ *
5798
+ * // Save to default path: ./logs/live/my-strategy.md
5799
+ * await service.dump("my-strategy");
5800
+ *
5801
+ * // Save to custom path: ./custom/path/my-strategy.md
5802
+ * await service.dump("my-strategy", "./custom/path");
5803
+ * ```
5804
+ */
5805
+ this.dump = async (strategyName, path = "./logs/live") => {
5806
+ this.loggerService.log("liveMarkdownService dump", {
5807
+ strategyName,
5808
+ path,
5809
+ });
5810
+ const storage = this.getStorage(strategyName);
5811
+ await storage.dump(strategyName, path);
5812
+ };
5813
+ /**
5814
+ * Clears accumulated event data from storage.
5815
+ * If strategyName is provided, clears only that strategy's data.
5816
+ * If strategyName is omitted, clears all strategies' data.
5817
+ *
5818
+ * @param strategyName - Optional strategy name to clear specific strategy data
5819
+ *
5820
+ * @example
5821
+ * ```typescript
5822
+ * const service = new LiveMarkdownService();
5823
+ *
5824
+ * // Clear specific strategy data
5825
+ * await service.clear("my-strategy");
5826
+ *
5827
+ * // Clear all strategies' data
5828
+ * await service.clear();
5829
+ * ```
5830
+ */
5831
+ this.clear = async (strategyName) => {
5832
+ this.loggerService.log("liveMarkdownService clear", {
5833
+ strategyName,
5834
+ });
5835
+ this.getStorage.clear(strategyName);
5836
+ };
5837
+ /**
5838
+ * Initializes the service by subscribing to live signal events.
5839
+ * Uses singleshot to ensure initialization happens only once.
5840
+ * Automatically called on first use.
5841
+ *
5842
+ * @example
5843
+ * ```typescript
5844
+ * const service = new LiveMarkdownService();
5845
+ * await service.init(); // Subscribe to live events
5846
+ * ```
5847
+ */
5848
+ this.init = singleshot(async () => {
5849
+ this.loggerService.log("liveMarkdownService init");
5850
+ signalLiveEmitter.subscribe(this.tick);
5851
+ });
5852
+ }
5853
+ }
5854
+
5855
+ const columns$1 = [
5856
+ {
5857
+ key: "timestamp",
5858
+ label: "Timestamp",
5859
+ format: (data) => new Date(data.timestamp).toISOString(),
5860
+ },
5861
+ {
5862
+ key: "action",
5863
+ label: "Action",
5864
+ format: (data) => data.action.toUpperCase(),
5865
+ },
5866
+ {
5867
+ key: "symbol",
5868
+ label: "Symbol",
5869
+ format: (data) => data.symbol,
5870
+ },
5871
+ {
5872
+ key: "signalId",
5873
+ label: "Signal ID",
5874
+ format: (data) => data.signalId,
5875
+ },
5876
+ {
5877
+ key: "position",
5878
+ label: "Position",
5879
+ format: (data) => data.position.toUpperCase(),
5880
+ },
5881
+ {
5882
+ key: "note",
5883
+ label: "Note",
5884
+ format: (data) => data.note ?? "N/A",
5885
+ },
5886
+ {
5887
+ key: "currentPrice",
5888
+ label: "Current Price",
5889
+ format: (data) => `${data.currentPrice.toFixed(8)} USD`,
5890
+ },
5891
+ {
5892
+ key: "priceOpen",
5893
+ label: "Entry Price",
5894
+ format: (data) => `${data.priceOpen.toFixed(8)} USD`,
5895
+ },
5896
+ {
5897
+ key: "takeProfit",
5898
+ label: "Take Profit",
5899
+ format: (data) => `${data.takeProfit.toFixed(8)} USD`,
5900
+ },
5901
+ {
5902
+ key: "stopLoss",
5903
+ label: "Stop Loss",
5904
+ format: (data) => `${data.stopLoss.toFixed(8)} USD`,
5905
+ },
5906
+ {
5907
+ key: "duration",
5908
+ label: "Wait Time (min)",
5909
+ format: (data) => data.duration !== undefined ? `${data.duration}` : "N/A",
5910
+ },
5911
+ ];
5912
+ /** Maximum number of events to store in schedule reports */
5913
+ const MAX_EVENTS$1 = 250;
5914
+ /**
5915
+ * Storage class for accumulating scheduled signal events per strategy.
5916
+ * Maintains a chronological list of scheduled and cancelled events.
5917
+ */
5918
+ let ReportStorage$1 = class ReportStorage {
5919
+ constructor() {
5920
+ /** Internal list of all scheduled events for this strategy */
5921
+ this._eventList = [];
5922
+ }
5923
+ /**
5924
+ * Adds a scheduled event to the storage.
5925
+ *
5926
+ * @param data - Scheduled tick result
5927
+ */
5928
+ addScheduledEvent(data) {
5929
+ this._eventList.push({
5930
+ timestamp: data.signal.scheduledAt,
5931
+ action: "scheduled",
5932
+ symbol: data.signal.symbol,
5933
+ signalId: data.signal.id,
5934
+ position: data.signal.position,
5935
+ note: data.signal.note,
5936
+ currentPrice: data.currentPrice,
5937
+ priceOpen: data.signal.priceOpen,
5938
+ takeProfit: data.signal.priceTakeProfit,
5939
+ stopLoss: data.signal.priceStopLoss,
5940
+ });
5941
+ // Trim queue if exceeded MAX_EVENTS
5942
+ if (this._eventList.length > MAX_EVENTS$1) {
5943
+ this._eventList.shift();
5944
+ }
5945
+ }
5946
+ /**
5947
+ * Updates or adds a cancelled event to the storage.
4598
5948
  * Replaces the previous event with the same signalId.
4599
5949
  *
4600
- * @param data - Closed tick result
5950
+ * @param data - Cancelled tick result
4601
5951
  */
4602
- addClosedEvent(data) {
4603
- const durationMs = data.closeTimestamp - data.signal.timestamp;
5952
+ addCancelledEvent(data) {
5953
+ const durationMs = data.closeTimestamp - data.signal.scheduledAt;
4604
5954
  const durationMin = Math.round(durationMs / 60000);
4605
5955
  // Find existing event with the same signalId
4606
5956
  const existingIndex = this._eventList.findIndex((event) => event.signalId === data.signal.id);
4607
5957
  const newEvent = {
4608
5958
  timestamp: data.closeTimestamp,
4609
- action: "closed",
5959
+ action: "cancelled",
4610
5960
  symbol: data.signal.symbol,
4611
5961
  signalId: data.signal.id,
4612
5962
  position: data.signal.position,
4613
5963
  note: data.signal.note,
4614
5964
  currentPrice: data.currentPrice,
4615
- openPrice: data.signal.priceOpen,
5965
+ priceOpen: data.signal.priceOpen,
4616
5966
  takeProfit: data.signal.priceTakeProfit,
4617
5967
  stopLoss: data.signal.priceStopLoss,
4618
- pnl: data.pnl.pnlPercentage,
4619
- closeReason: data.closeReason,
5968
+ closeTimestamp: data.closeTimestamp,
4620
5969
  duration: durationMin,
4621
5970
  };
4622
5971
  // Replace existing event or add new one
@@ -4632,8 +5981,7 @@ let ReportStorage$1 = class ReportStorage {
4632
5981
  }
4633
5982
  }
4634
5983
  /**
4635
- * Calculates statistical data from live trading events (Controller).
4636
- * Returns null for any unsafe numeric values (NaN, Infinity, etc).
5984
+ * Calculates statistical data from scheduled signal events (Controller).
4637
5985
  *
4638
5986
  * @returns Statistical data (empty object if no events)
4639
5987
  */
@@ -4642,78 +5990,34 @@ let ReportStorage$1 = class ReportStorage {
4642
5990
  return {
4643
5991
  eventList: [],
4644
5992
  totalEvents: 0,
4645
- totalClosed: 0,
4646
- winCount: 0,
4647
- lossCount: 0,
4648
- winRate: null,
4649
- avgPnl: null,
4650
- totalPnl: null,
4651
- stdDev: null,
4652
- sharpeRatio: null,
4653
- annualizedSharpeRatio: null,
4654
- certaintyRatio: null,
4655
- expectedYearlyReturns: null,
5993
+ totalScheduled: 0,
5994
+ totalCancelled: 0,
5995
+ cancellationRate: null,
5996
+ avgWaitTime: null,
4656
5997
  };
4657
5998
  }
4658
- const closedEvents = this._eventList.filter((e) => e.action === "closed");
4659
- const totalClosed = closedEvents.length;
4660
- const winCount = closedEvents.filter((e) => e.pnl && e.pnl > 0).length;
4661
- const lossCount = closedEvents.filter((e) => e.pnl && e.pnl < 0).length;
4662
- // Calculate basic statistics
4663
- const avgPnl = totalClosed > 0
4664
- ? closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0) / totalClosed
4665
- : 0;
4666
- const totalPnl = closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0);
4667
- const winRate = (winCount / totalClosed) * 100;
4668
- // Calculate Sharpe Ratio (risk-free rate = 0)
4669
- let sharpeRatio = 0;
4670
- let stdDev = 0;
4671
- if (totalClosed > 0) {
4672
- const returns = closedEvents.map((e) => e.pnl || 0);
4673
- const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / totalClosed;
4674
- stdDev = Math.sqrt(variance);
4675
- sharpeRatio = stdDev > 0 ? avgPnl / stdDev : 0;
4676
- }
4677
- const annualizedSharpeRatio = sharpeRatio * Math.sqrt(365);
4678
- // Calculate Certainty Ratio
4679
- let certaintyRatio = 0;
4680
- if (totalClosed > 0) {
4681
- const wins = closedEvents.filter((e) => e.pnl && e.pnl > 0);
4682
- const losses = closedEvents.filter((e) => e.pnl && e.pnl < 0);
4683
- const avgWin = wins.length > 0
4684
- ? wins.reduce((sum, e) => sum + (e.pnl || 0), 0) / wins.length
4685
- : 0;
4686
- const avgLoss = losses.length > 0
4687
- ? losses.reduce((sum, e) => sum + (e.pnl || 0), 0) / losses.length
4688
- : 0;
4689
- certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
4690
- }
4691
- // Calculate Expected Yearly Returns
4692
- let expectedYearlyReturns = 0;
4693
- if (totalClosed > 0) {
4694
- const avgDurationMin = closedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) / totalClosed;
4695
- const avgDurationDays = avgDurationMin / (60 * 24);
4696
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
4697
- expectedYearlyReturns = avgPnl * tradesPerYear;
4698
- }
5999
+ const scheduledEvents = this._eventList.filter((e) => e.action === "scheduled");
6000
+ const cancelledEvents = this._eventList.filter((e) => e.action === "cancelled");
6001
+ const totalScheduled = scheduledEvents.length;
6002
+ const totalCancelled = cancelledEvents.length;
6003
+ // Calculate cancellation rate
6004
+ const cancellationRate = totalScheduled > 0 ? (totalCancelled / totalScheduled) * 100 : null;
6005
+ // Calculate average wait time for cancelled signals
6006
+ const avgWaitTime = totalCancelled > 0
6007
+ ? cancelledEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
6008
+ totalCancelled
6009
+ : null;
4699
6010
  return {
4700
6011
  eventList: this._eventList,
4701
6012
  totalEvents: this._eventList.length,
4702
- totalClosed,
4703
- winCount,
4704
- lossCount,
4705
- winRate: isUnsafe$2(winRate) ? null : winRate,
4706
- avgPnl: isUnsafe$2(avgPnl) ? null : avgPnl,
4707
- totalPnl: isUnsafe$2(totalPnl) ? null : totalPnl,
4708
- stdDev: isUnsafe$2(stdDev) ? null : stdDev,
4709
- sharpeRatio: isUnsafe$2(sharpeRatio) ? null : sharpeRatio,
4710
- annualizedSharpeRatio: isUnsafe$2(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
4711
- certaintyRatio: isUnsafe$2(certaintyRatio) ? null : certaintyRatio,
4712
- expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
6013
+ totalScheduled,
6014
+ totalCancelled,
6015
+ cancellationRate,
6016
+ avgWaitTime,
4713
6017
  };
4714
6018
  }
4715
6019
  /**
4716
- * Generates markdown report with all tick events for a strategy (View).
6020
+ * Generates markdown report with all scheduled events for a strategy (View).
4717
6021
  *
4718
6022
  * @param strategyName - Strategy name
4719
6023
  * @returns Markdown formatted report with all events
@@ -4721,22 +6025,22 @@ let ReportStorage$1 = class ReportStorage {
4721
6025
  async getReport(strategyName) {
4722
6026
  const stats = await this.getData();
4723
6027
  if (stats.totalEvents === 0) {
4724
- return str.newline(`# Live Trading Report: ${strategyName}`, "", "No events recorded yet.");
6028
+ return str.newline(`# Scheduled Signals Report: ${strategyName}`, "", "No scheduled signals recorded yet.");
4725
6029
  }
4726
6030
  const header = columns$1.map((col) => col.label);
4727
6031
  const separator = columns$1.map(() => "---");
4728
6032
  const rows = this._eventList.map((event) => columns$1.map((col) => col.format(event)));
4729
6033
  const tableData = [header, separator, ...rows];
4730
- const table = str.newline(tableData.map(row => `| ${row.join(" | ")} |`));
4731
- return str.newline(`# Live Trading Report: ${strategyName}`, "", table, "", `**Total events:** ${stats.totalEvents}`, `**Closed signals:** ${stats.totalClosed}`, `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`, `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`, `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`, `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`, `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`, `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`, `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`, `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`);
6034
+ const table = str.newline(tableData.map((row) => `| ${row.join(" | ")} |`));
6035
+ return str.newline(`# Scheduled Signals Report: ${strategyName}`, "", table, "", `**Total events:** ${stats.totalEvents}`, `**Scheduled signals:** ${stats.totalScheduled}`, `**Cancelled signals:** ${stats.totalCancelled}`, `**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`, `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`);
4732
6036
  }
4733
6037
  /**
4734
6038
  * Saves strategy report to disk.
4735
6039
  *
4736
6040
  * @param strategyName - Strategy name
4737
- * @param path - Directory path to save report (default: "./logs/live")
6041
+ * @param path - Directory path to save report (default: "./logs/schedule")
4738
6042
  */
4739
- async dump(strategyName, path = "./logs/live") {
6043
+ async dump(strategyName, path = "./logs/schedule") {
4740
6044
  const markdown = await this.getReport(strategyName);
4741
6045
  try {
4742
6046
  const dir = join(process.cwd(), path);
@@ -4744,7 +6048,7 @@ let ReportStorage$1 = class ReportStorage {
4744
6048
  const filename = `${strategyName}.md`;
4745
6049
  const filepath = join(dir, filename);
4746
6050
  await writeFile(filepath, markdown, "utf-8");
4747
- console.log(`Live trading report saved: ${filepath}`);
6051
+ console.log(`Scheduled signals report saved: ${filepath}`);
4748
6052
  }
4749
6053
  catch (error) {
4750
6054
  console.error(`Failed to save markdown report:`, error);
@@ -4752,36 +6056,27 @@ let ReportStorage$1 = class ReportStorage {
4752
6056
  }
4753
6057
  };
4754
6058
  /**
4755
- * Service for generating and saving live trading markdown reports.
6059
+ * Service for generating and saving scheduled signals markdown reports.
4756
6060
  *
4757
6061
  * Features:
4758
- * - Listens to all signal events via onTick callback
4759
- * - Accumulates all events (idle, opened, active, closed) per strategy
6062
+ * - Listens to scheduled and cancelled signal events via signalLiveEmitter
6063
+ * - Accumulates all events (scheduled, cancelled) per strategy
4760
6064
  * - Generates markdown tables with detailed event information
4761
- * - Provides trading statistics (win rate, average PNL)
4762
- * - Saves reports to disk in logs/live/{strategyName}.md
6065
+ * - Provides statistics (cancellation rate, average wait time)
6066
+ * - Saves reports to disk in logs/schedule/{strategyName}.md
4763
6067
  *
4764
6068
  * @example
4765
6069
  * ```typescript
4766
- * const service = new LiveMarkdownService();
6070
+ * const service = new ScheduleMarkdownService();
4767
6071
  *
4768
- * // Add to strategy callbacks
4769
- * addStrategy({
4770
- * strategyName: "my-strategy",
4771
- * callbacks: {
4772
- * onTick: (symbol, result, backtest) => {
4773
- * if (!backtest) {
4774
- * service.tick(result);
4775
- * }
4776
- * }
4777
- * }
4778
- * });
6072
+ * // Service automatically subscribes to signalLiveEmitter on init
6073
+ * // No manual callback setup needed
4779
6074
  *
4780
6075
  * // Later: generate and save report
4781
6076
  * await service.dump("my-strategy");
4782
6077
  * ```
4783
6078
  */
4784
- class LiveMarkdownService {
6079
+ class ScheduleMarkdownService {
4785
6080
  constructor() {
4786
6081
  /** Logger service for debug output */
4787
6082
  this.loggerService = inject(TYPES.loggerService);
@@ -4791,46 +6086,33 @@ class LiveMarkdownService {
4791
6086
  */
4792
6087
  this.getStorage = memoize(([strategyName]) => `${strategyName}`, () => new ReportStorage$1());
4793
6088
  /**
4794
- * Processes tick events and accumulates all event types.
4795
- * Should be called from IStrategyCallbacks.onTick.
6089
+ * Processes tick events and accumulates scheduled/cancelled events.
6090
+ * Should be called from signalLiveEmitter subscription.
4796
6091
  *
4797
- * Processes all event types: idle, opened, active, closed.
6092
+ * Processes only scheduled and cancelled event types.
4798
6093
  *
4799
6094
  * @param data - Tick result from strategy execution
4800
6095
  *
4801
6096
  * @example
4802
6097
  * ```typescript
4803
- * const service = new LiveMarkdownService();
4804
- *
4805
- * callbacks: {
4806
- * onTick: (symbol, result, backtest) => {
4807
- * if (!backtest) {
4808
- * service.tick(result);
4809
- * }
4810
- * }
4811
- * }
6098
+ * const service = new ScheduleMarkdownService();
6099
+ * // Service automatically subscribes in init()
4812
6100
  * ```
4813
6101
  */
4814
6102
  this.tick = async (data) => {
4815
- this.loggerService.log("liveMarkdownService tick", {
6103
+ this.loggerService.log("scheduleMarkdownService tick", {
4816
6104
  data,
4817
6105
  });
4818
6106
  const storage = this.getStorage(data.strategyName);
4819
- if (data.action === "idle") {
4820
- storage.addIdleEvent(data.currentPrice);
4821
- }
4822
- else if (data.action === "opened") {
4823
- storage.addOpenedEvent(data);
4824
- }
4825
- else if (data.action === "active") {
4826
- storage.addActiveEvent(data);
6107
+ if (data.action === "scheduled") {
6108
+ storage.addScheduledEvent(data);
4827
6109
  }
4828
- else if (data.action === "closed") {
4829
- storage.addClosedEvent(data);
6110
+ else if (data.action === "cancelled") {
6111
+ storage.addCancelledEvent(data);
4830
6112
  }
4831
6113
  };
4832
6114
  /**
4833
- * Gets statistical data from all live trading events for a strategy.
6115
+ * Gets statistical data from all scheduled signal events for a strategy.
4834
6116
  * Delegates to ReportStorage.getData().
4835
6117
  *
4836
6118
  * @param strategyName - Strategy name to get data for
@@ -4838,20 +6120,20 @@ class LiveMarkdownService {
4838
6120
  *
4839
6121
  * @example
4840
6122
  * ```typescript
4841
- * const service = new LiveMarkdownService();
6123
+ * const service = new ScheduleMarkdownService();
4842
6124
  * const stats = await service.getData("my-strategy");
4843
- * console.log(stats.sharpeRatio, stats.winRate);
6125
+ * console.log(stats.cancellationRate, stats.avgWaitTime);
4844
6126
  * ```
4845
6127
  */
4846
6128
  this.getData = async (strategyName) => {
4847
- this.loggerService.log("liveMarkdownService getData", {
6129
+ this.loggerService.log("scheduleMarkdownService getData", {
4848
6130
  strategyName,
4849
6131
  });
4850
6132
  const storage = this.getStorage(strategyName);
4851
6133
  return storage.getData();
4852
6134
  };
4853
6135
  /**
4854
- * Generates markdown report with all events for a strategy.
6136
+ * Generates markdown report with all scheduled events for a strategy.
4855
6137
  * Delegates to ReportStorage.getReport().
4856
6138
  *
4857
6139
  * @param strategyName - Strategy name to generate report for
@@ -4859,13 +6141,13 @@ class LiveMarkdownService {
4859
6141
  *
4860
6142
  * @example
4861
6143
  * ```typescript
4862
- * const service = new LiveMarkdownService();
6144
+ * const service = new ScheduleMarkdownService();
4863
6145
  * const markdown = await service.getReport("my-strategy");
4864
6146
  * console.log(markdown);
4865
6147
  * ```
4866
6148
  */
4867
6149
  this.getReport = async (strategyName) => {
4868
- this.loggerService.log("liveMarkdownService getReport", {
6150
+ this.loggerService.log("scheduleMarkdownService getReport", {
4869
6151
  strategyName,
4870
6152
  });
4871
6153
  const storage = this.getStorage(strategyName);
@@ -4877,21 +6159,21 @@ class LiveMarkdownService {
4877
6159
  * Delegates to ReportStorage.dump().
4878
6160
  *
4879
6161
  * @param strategyName - Strategy name to save report for
4880
- * @param path - Directory path to save report (default: "./logs/live")
6162
+ * @param path - Directory path to save report (default: "./logs/schedule")
4881
6163
  *
4882
6164
  * @example
4883
6165
  * ```typescript
4884
- * const service = new LiveMarkdownService();
6166
+ * const service = new ScheduleMarkdownService();
4885
6167
  *
4886
- * // Save to default path: ./logs/live/my-strategy.md
6168
+ * // Save to default path: ./logs/schedule/my-strategy.md
4887
6169
  * await service.dump("my-strategy");
4888
6170
  *
4889
6171
  * // Save to custom path: ./custom/path/my-strategy.md
4890
6172
  * await service.dump("my-strategy", "./custom/path");
4891
6173
  * ```
4892
6174
  */
4893
- this.dump = async (strategyName, path = "./logs/live") => {
4894
- this.loggerService.log("liveMarkdownService dump", {
6175
+ this.dump = async (strategyName, path = "./logs/schedule") => {
6176
+ this.loggerService.log("scheduleMarkdownService dump", {
4895
6177
  strategyName,
4896
6178
  path,
4897
6179
  });
@@ -4907,7 +6189,7 @@ class LiveMarkdownService {
4907
6189
  *
4908
6190
  * @example
4909
6191
  * ```typescript
4910
- * const service = new LiveMarkdownService();
6192
+ * const service = new ScheduleMarkdownService();
4911
6193
  *
4912
6194
  * // Clear specific strategy data
4913
6195
  * await service.clear("my-strategy");
@@ -4917,7 +6199,7 @@ class LiveMarkdownService {
4917
6199
  * ```
4918
6200
  */
4919
6201
  this.clear = async (strategyName) => {
4920
- this.loggerService.log("liveMarkdownService clear", {
6202
+ this.loggerService.log("scheduleMarkdownService clear", {
4921
6203
  strategyName,
4922
6204
  });
4923
6205
  this.getStorage.clear(strategyName);
@@ -4929,13 +6211,13 @@ class LiveMarkdownService {
4929
6211
  *
4930
6212
  * @example
4931
6213
  * ```typescript
4932
- * const service = new LiveMarkdownService();
6214
+ * const service = new ScheduleMarkdownService();
4933
6215
  * await service.init(); // Subscribe to live events
4934
6216
  * ```
4935
6217
  */
4936
6218
  this.init = singleshot(async () => {
4937
- this.loggerService.log("liveMarkdownService init");
4938
- signalLiveEmitter.subscribe(this.tick);
6219
+ this.loggerService.log("scheduleMarkdownService init");
6220
+ signalEmitter.subscribe(this.tick);
4939
6221
  });
4940
6222
  }
4941
6223
  }
@@ -6499,6 +7781,7 @@ class RiskValidationService {
6499
7781
  {
6500
7782
  provide(TYPES.backtestMarkdownService, () => new BacktestMarkdownService());
6501
7783
  provide(TYPES.liveMarkdownService, () => new LiveMarkdownService());
7784
+ provide(TYPES.scheduleMarkdownService, () => new ScheduleMarkdownService());
6502
7785
  provide(TYPES.performanceMarkdownService, () => new PerformanceMarkdownService());
6503
7786
  provide(TYPES.walkerMarkdownService, () => new WalkerMarkdownService());
6504
7787
  provide(TYPES.heatMarkdownService, () => new HeatMarkdownService());
@@ -6557,6 +7840,7 @@ const logicPublicServices = {
6557
7840
  const markdownServices = {
6558
7841
  backtestMarkdownService: inject(TYPES.backtestMarkdownService),
6559
7842
  liveMarkdownService: inject(TYPES.liveMarkdownService),
7843
+ scheduleMarkdownService: inject(TYPES.scheduleMarkdownService),
6560
7844
  performanceMarkdownService: inject(TYPES.performanceMarkdownService),
6561
7845
  walkerMarkdownService: inject(TYPES.walkerMarkdownService),
6562
7846
  heatMarkdownService: inject(TYPES.heatMarkdownService),
@@ -6603,6 +7887,20 @@ var backtest$1 = backtest;
6603
7887
  async function setLogger(logger) {
6604
7888
  backtest$1.loggerService.setLogger(logger);
6605
7889
  }
7890
+ /**
7891
+ * Sets global configuration parameters for the framework.
7892
+ * @param config - Partial configuration object to override default settings
7893
+ *
7894
+ * @example
7895
+ * ```typescript
7896
+ * setConfig({
7897
+ * CC_SCHEDULE_AWAIT_MINUTES: 90,
7898
+ * });
7899
+ * ```
7900
+ */
7901
+ async function setConfig(config) {
7902
+ Object.assign(GLOBAL_CONFIG, config);
7903
+ }
6606
7904
 
6607
7905
  const ADD_STRATEGY_METHOD_NAME = "add.addStrategy";
6608
7906
  const ADD_EXCHANGE_METHOD_NAME = "add.addExchange";
@@ -7885,8 +9183,17 @@ class BacktestUtils {
7885
9183
  symbol,
7886
9184
  context,
7887
9185
  });
7888
- backtest$1.backtestMarkdownService.clear(context.strategyName);
7889
- backtest$1.strategyGlobalService.clear(context.strategyName);
9186
+ {
9187
+ backtest$1.backtestMarkdownService.clear(context.strategyName);
9188
+ backtest$1.scheduleMarkdownService.clear(context.strategyName);
9189
+ }
9190
+ {
9191
+ backtest$1.strategyGlobalService.clear(context.strategyName);
9192
+ }
9193
+ {
9194
+ const { riskName } = backtest$1.strategySchemaService.get(context.strategyName);
9195
+ riskName && backtest$1.riskGlobalService.clear(riskName);
9196
+ }
7890
9197
  return backtest$1.backtestGlobalService.run(symbol, context);
7891
9198
  };
7892
9199
  /**
@@ -8065,8 +9372,17 @@ class LiveUtils {
8065
9372
  symbol,
8066
9373
  context,
8067
9374
  });
8068
- backtest$1.liveMarkdownService.clear(context.strategyName);
8069
- backtest$1.strategyGlobalService.clear(context.strategyName);
9375
+ {
9376
+ backtest$1.liveMarkdownService.clear(context.strategyName);
9377
+ backtest$1.scheduleMarkdownService.clear(context.strategyName);
9378
+ }
9379
+ {
9380
+ backtest$1.strategyGlobalService.clear(context.strategyName);
9381
+ }
9382
+ {
9383
+ const { riskName } = backtest$1.strategySchemaService.get(context.strategyName);
9384
+ riskName && backtest$1.riskGlobalService.clear(riskName);
9385
+ }
8070
9386
  return backtest$1.liveGlobalService.run(symbol, context);
8071
9387
  };
8072
9388
  /**
@@ -8192,6 +9508,132 @@ class LiveUtils {
8192
9508
  */
8193
9509
  const Live = new LiveUtils();
8194
9510
 
9511
+ const SCHEDULE_METHOD_NAME_GET_DATA = "ScheduleUtils.getData";
9512
+ const SCHEDULE_METHOD_NAME_GET_REPORT = "ScheduleUtils.getReport";
9513
+ const SCHEDULE_METHOD_NAME_DUMP = "ScheduleUtils.dump";
9514
+ const SCHEDULE_METHOD_NAME_CLEAR = "ScheduleUtils.clear";
9515
+ /**
9516
+ * Utility class for scheduled signals reporting operations.
9517
+ *
9518
+ * Provides simplified access to scheduleMarkdownService with logging.
9519
+ * Exported as singleton instance for convenient usage.
9520
+ *
9521
+ * Features:
9522
+ * - Track scheduled signals in queue
9523
+ * - Track cancelled signals
9524
+ * - Calculate cancellation rate and average wait time
9525
+ * - Generate markdown reports
9526
+ *
9527
+ * @example
9528
+ * ```typescript
9529
+ * import { Schedule } from "./classes/Schedule";
9530
+ *
9531
+ * // Get scheduled signals statistics
9532
+ * const stats = await Schedule.getData("my-strategy");
9533
+ * console.log(`Cancellation rate: ${stats.cancellationRate}%`);
9534
+ * console.log(`Average wait time: ${stats.avgWaitTime} minutes`);
9535
+ *
9536
+ * // Generate and save report
9537
+ * await Schedule.dump("my-strategy");
9538
+ * ```
9539
+ */
9540
+ class ScheduleUtils {
9541
+ constructor() {
9542
+ /**
9543
+ * Gets statistical data from all scheduled signal events for a strategy.
9544
+ *
9545
+ * @param strategyName - Strategy name to get data for
9546
+ * @returns Promise resolving to statistical data object
9547
+ *
9548
+ * @example
9549
+ * ```typescript
9550
+ * const stats = await Schedule.getData("my-strategy");
9551
+ * console.log(stats.cancellationRate, stats.avgWaitTime);
9552
+ * ```
9553
+ */
9554
+ this.getData = async (strategyName) => {
9555
+ backtest$1.loggerService.info(SCHEDULE_METHOD_NAME_GET_DATA, {
9556
+ strategyName,
9557
+ });
9558
+ return await backtest$1.scheduleMarkdownService.getData(strategyName);
9559
+ };
9560
+ /**
9561
+ * Generates markdown report with all scheduled events for a strategy.
9562
+ *
9563
+ * @param strategyName - Strategy name to generate report for
9564
+ * @returns Promise resolving to markdown formatted report string
9565
+ *
9566
+ * @example
9567
+ * ```typescript
9568
+ * const markdown = await Schedule.getReport("my-strategy");
9569
+ * console.log(markdown);
9570
+ * ```
9571
+ */
9572
+ this.getReport = async (strategyName) => {
9573
+ backtest$1.loggerService.info(SCHEDULE_METHOD_NAME_GET_REPORT, {
9574
+ strategyName,
9575
+ });
9576
+ return await backtest$1.scheduleMarkdownService.getReport(strategyName);
9577
+ };
9578
+ /**
9579
+ * Saves strategy report to disk.
9580
+ *
9581
+ * @param strategyName - Strategy name to save report for
9582
+ * @param path - Optional directory path to save report (default: "./logs/schedule")
9583
+ *
9584
+ * @example
9585
+ * ```typescript
9586
+ * // Save to default path: ./logs/schedule/my-strategy.md
9587
+ * await Schedule.dump("my-strategy");
9588
+ *
9589
+ * // Save to custom path: ./custom/path/my-strategy.md
9590
+ * await Schedule.dump("my-strategy", "./custom/path");
9591
+ * ```
9592
+ */
9593
+ this.dump = async (strategyName, path) => {
9594
+ backtest$1.loggerService.info(SCHEDULE_METHOD_NAME_DUMP, {
9595
+ strategyName,
9596
+ path,
9597
+ });
9598
+ await backtest$1.scheduleMarkdownService.dump(strategyName, path);
9599
+ };
9600
+ /**
9601
+ * Clears accumulated scheduled signal data from storage.
9602
+ * If strategyName is provided, clears only that strategy's data.
9603
+ * If strategyName is omitted, clears all strategies' data.
9604
+ *
9605
+ * @param strategyName - Optional strategy name to clear specific strategy data
9606
+ *
9607
+ * @example
9608
+ * ```typescript
9609
+ * // Clear specific strategy data
9610
+ * await Schedule.clear("my-strategy");
9611
+ *
9612
+ * // Clear all strategies' data
9613
+ * await Schedule.clear();
9614
+ * ```
9615
+ */
9616
+ this.clear = async (strategyName) => {
9617
+ backtest$1.loggerService.info(SCHEDULE_METHOD_NAME_CLEAR, {
9618
+ strategyName,
9619
+ });
9620
+ await backtest$1.scheduleMarkdownService.clear(strategyName);
9621
+ };
9622
+ }
9623
+ }
9624
+ /**
9625
+ * Singleton instance of ScheduleUtils for convenient scheduled signals reporting.
9626
+ *
9627
+ * @example
9628
+ * ```typescript
9629
+ * import { Schedule } from "./classes/Schedule";
9630
+ *
9631
+ * const stats = await Schedule.getData("my-strategy");
9632
+ * console.log("Cancellation rate:", stats.cancellationRate);
9633
+ * ```
9634
+ */
9635
+ const Schedule = new ScheduleUtils();
9636
+
8195
9637
  /**
8196
9638
  * Performance class provides static methods for performance metrics analysis.
8197
9639
  *
@@ -8367,8 +9809,17 @@ class WalkerUtils {
8367
9809
  backtest$1.walkerMarkdownService.clear(context.walkerName);
8368
9810
  // Clear backtest data for all strategies
8369
9811
  for (const strategyName of walkerSchema.strategies) {
8370
- backtest$1.backtestMarkdownService.clear(strategyName);
8371
- backtest$1.strategyGlobalService.clear(strategyName);
9812
+ {
9813
+ backtest$1.backtestMarkdownService.clear(strategyName);
9814
+ backtest$1.scheduleMarkdownService.clear(strategyName);
9815
+ }
9816
+ {
9817
+ backtest$1.strategyGlobalService.clear(strategyName);
9818
+ }
9819
+ {
9820
+ const { riskName } = backtest$1.strategySchemaService.get(strategyName);
9821
+ riskName && backtest$1.riskGlobalService.clear(riskName);
9822
+ }
8372
9823
  }
8373
9824
  return backtest$1.walkerGlobalService.run(symbol, {
8374
9825
  walkerName: context.walkerName,
@@ -8418,6 +9869,9 @@ class WalkerUtils {
8418
9869
  task().catch((error) => errorEmitter.next(new Error(getErrorMessage(error))));
8419
9870
  return () => {
8420
9871
  isStopped = true;
9872
+ for (const strategyName of walkerSchema.strategies) {
9873
+ backtest$1.strategyGlobalService.stop(strategyName);
9874
+ }
8421
9875
  };
8422
9876
  };
8423
9877
  /**
@@ -8773,4 +10227,4 @@ PositionSizeUtils.atrBased = async (symbol, accountBalance, priceOpen, atr, cont
8773
10227
  };
8774
10228
  const PositionSize = PositionSizeUtils;
8775
10229
 
8776
- export { Backtest, ExecutionContextService, Heat, Live, MethodContextService, Performance, PersistBase, PersistRiskAdapter, PersistSignalAdaper, PositionSize, Walker, addExchange, addFrame, addRisk, addSizing, addStrategy, addWalker, emitters, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listExchanges, listFrames, listRisks, listSizings, listStrategies, listWalkers, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenPerformance, listenProgress, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, setLogger };
10230
+ export { Backtest, ExecutionContextService, Heat, Live, MethodContextService, Performance, PersistBase, PersistRiskAdapter, PersistSignalAdapter, PositionSize, Schedule, Walker, addExchange, addFrame, addRisk, addSizing, addStrategy, addWalker, emitters, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listExchanges, listFrames, listRisks, listSizings, listStrategies, listWalkers, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenPerformance, listenProgress, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, setConfig, setLogger };