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