backtest-kit 13.0.0 → 13.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.cjs CHANGED
@@ -6837,6 +6837,119 @@ const validateScheduledSignal = (signal, currentPrice) => {
6837
6837
  }
6838
6838
  };
6839
6839
 
6840
+ /**
6841
+ * Validates ISignalDto returned by getSignal, branching on the same logic as ClientStrategy GET_SIGNAL_FN.
6842
+ *
6843
+ * When priceOpen is provided:
6844
+ * - If currentPrice already reached priceOpen (shouldActivateImmediately) →
6845
+ * validates as pending: currentPrice must be between SL and TP
6846
+ * - Otherwise → validates as scheduled: priceOpen must be between SL and TP
6847
+ *
6848
+ * When priceOpen is absent:
6849
+ * - Validates as pending: currentPrice must be between SL and TP
6850
+ *
6851
+ * Checks:
6852
+ * - currentPrice is a finite positive number
6853
+ * - Common signal fields via validateCommonSignal (position, prices, TP/SL relationships, minuteEstimatedTime)
6854
+ * - Position-specific immediate-close protection (pending) or activation-close protection (scheduled)
6855
+ *
6856
+ * @param signal - Signal DTO returned by getSignal
6857
+ * @param currentPrice - Current market price at the moment of signal creation
6858
+ * @returns true if signal is valid, false if validation errors were found (errors logged to console.error)
6859
+ */
6860
+ const validateSignal = (signal, currentPrice) => {
6861
+ const errors = [];
6862
+ // ЗАЩИТА ОТ NaN/Infinity: currentPrice должна быть конечным числом
6863
+ {
6864
+ if (typeof currentPrice !== "number") {
6865
+ errors.push(`currentPrice must be a number type, got ${currentPrice} (${typeof currentPrice})`);
6866
+ }
6867
+ if (!isFinite(currentPrice)) {
6868
+ errors.push(`currentPrice must be a finite number, got ${currentPrice} (${typeof currentPrice})`);
6869
+ }
6870
+ if (isFinite(currentPrice) && currentPrice <= 0) {
6871
+ errors.push(`currentPrice must be positive, got ${currentPrice}`);
6872
+ }
6873
+ }
6874
+ if (errors.length > 0) {
6875
+ console.error(`Invalid signal for ${signal.position} position (${signal.symbol || "empty symbol"}):\n${errors.join("\n")}`);
6876
+ return false;
6877
+ }
6878
+ try {
6879
+ validateCommonSignal(signal);
6880
+ }
6881
+ catch (error) {
6882
+ console.error(functoolsKit.getErrorMessage(error));
6883
+ return false;
6884
+ }
6885
+ // Определяем режим валидации по той же логике что в GET_SIGNAL_FN:
6886
+ // - нет priceOpen → pending (открывается по currentPrice)
6887
+ // - priceOpen задан и уже достигнут (shouldActivateImmediately) → pending
6888
+ // - priceOpen задан и ещё не достигнут → scheduled
6889
+ const hasPriceOpen = signal.priceOpen !== undefined;
6890
+ const shouldActivateImmediately = hasPriceOpen && ((signal.position === "long" && currentPrice <= signal.priceOpen) ||
6891
+ (signal.position === "short" && currentPrice >= signal.priceOpen));
6892
+ const isScheduled = hasPriceOpen && !shouldActivateImmediately;
6893
+ if (isScheduled) {
6894
+ // Scheduled: priceOpen должен быть между SL и TP (активация не даст моментального закрытия)
6895
+ if (signal.position === "long") {
6896
+ if (isFinite(signal.priceOpen)) {
6897
+ if (signal.priceOpen <= signal.priceStopLoss) {
6898
+ errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
6899
+ `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
6900
+ }
6901
+ if (signal.priceOpen >= signal.priceTakeProfit) {
6902
+ errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
6903
+ `Signal would close immediately on activation. This is logically impossible for LONG position.`);
6904
+ }
6905
+ }
6906
+ }
6907
+ if (signal.position === "short") {
6908
+ if (isFinite(signal.priceOpen)) {
6909
+ if (signal.priceOpen >= signal.priceStopLoss) {
6910
+ errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
6911
+ `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
6912
+ }
6913
+ if (signal.priceOpen <= signal.priceTakeProfit) {
6914
+ errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
6915
+ `Signal would close immediately on activation. This is logically impossible for SHORT position.`);
6916
+ }
6917
+ }
6918
+ }
6919
+ }
6920
+ else {
6921
+ // Pending: currentPrice должна быть между SL и TP (позиция не закроется сразу после открытия)
6922
+ if (signal.position === "long") {
6923
+ if (isFinite(currentPrice)) {
6924
+ if (currentPrice <= signal.priceStopLoss) {
6925
+ errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
6926
+ `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
6927
+ }
6928
+ if (currentPrice >= signal.priceTakeProfit) {
6929
+ errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
6930
+ `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
6931
+ }
6932
+ }
6933
+ }
6934
+ if (signal.position === "short") {
6935
+ if (isFinite(currentPrice)) {
6936
+ if (currentPrice >= signal.priceStopLoss) {
6937
+ errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
6938
+ `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
6939
+ }
6940
+ if (currentPrice <= signal.priceTakeProfit) {
6941
+ errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
6942
+ `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
6943
+ }
6944
+ }
6945
+ }
6946
+ }
6947
+ if (errors.length > 0) {
6948
+ console.error(`Invalid signal for ${signal.position} position (${signal.symbol || "empty symbol"}):\n${errors.join("\n")}`);
6949
+ }
6950
+ return !errors.length;
6951
+ };
6952
+
6840
6953
  const INTERVAL_MINUTES$8 = {
6841
6954
  "1m": 1,
6842
6955
  "3m": 3,
@@ -7303,11 +7416,27 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
7303
7416
  self._lastSignalTimestamp = alignedTime;
7304
7417
  }
7305
7418
  const currentPrice = await self.params.exchange.getAveragePrice(self.params.execution.context.symbol);
7306
- const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
7307
- const signal = await Promise.race([
7308
- self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7309
- functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
7310
- ]);
7419
+ // PRIORITY: a user-queued createPending/createScheduled DTO (set out of async-hooks
7420
+ // context) takes precedence over params.getSignal. When present it is consumed once
7421
+ // here — the slot is cleared and the snapshot rewritten — and then flows through the
7422
+ // exact same pipeline (risk check, priceOpen branching, onSignalSync on open) as a
7423
+ // signal returned by getSignal would.
7424
+ let signal;
7425
+ {
7426
+ if (!self._userSignal) {
7427
+ const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
7428
+ signal = await Promise.race([
7429
+ self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7430
+ functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
7431
+ ]);
7432
+ }
7433
+ if (self._userSignal) {
7434
+ signal = self._userSignal;
7435
+ self._userSignal = null;
7436
+ await PERSIST_STRATEGY_FN(self);
7437
+ }
7438
+ self._userSignal = null;
7439
+ }
7311
7440
  if (typeof signal === "symbol") {
7312
7441
  throw new Error(`Timeout for ${self.params.method.context.strategyName} symbol=${self.params.execution.context.symbol}`);
7313
7442
  }
@@ -7462,18 +7591,20 @@ const WAIT_FOR_INIT_FN$4 = async (self) => {
7462
7591
  self._lastPendingId = recentSignal.id;
7463
7592
  }
7464
7593
  }
7465
- // Restore deferred strategy state (commit queue + deferred user actions) so any
7594
+ // Read deferred strategy state (commit queue + deferred user actions) so any
7466
7595
  // confirmed-but-not-yet-forwarded broker operation survives a live crash and is
7467
- // re-drained on the next tick. Placed before the pending/scheduled restore which
7468
- // may early-return on exchange/strategy mismatch.
7469
- {
7470
- const strategyData = await PersistStrategyAdapter.readStrategyData(self.params.execution.context.symbol, self.params.strategyName, self.params.exchangeName);
7471
- if (strategyData) {
7472
- self._commitQueue = strategyData.commitQueue ?? [];
7473
- self._closedSignal = strategyData.closedSignal;
7474
- self._cancelledSignal = strategyData.cancelledSignal;
7475
- self._activatedSignal = strategyData.activatedSignal;
7476
- }
7596
+ // re-drained on the next tick. Read here, before the pending restore which may
7597
+ // early-return on exchange/strategy mismatch.
7598
+ const strategyData = await PersistStrategyAdapter.readStrategyData(self.params.execution.context.symbol, self.params.strategyName, self.params.exchangeName);
7599
+ if (strategyData) {
7600
+ // Deferred user actions are restored unconditionally: a deferred close belongs to
7601
+ // an already-cleared _pendingSignal, and cancel/activate belong to the scheduled
7602
+ // signal — none of them are tied to the currently-pending signal's id. A queued
7603
+ // createSignal is a not-yet-consumed signal source and likewise stands on its own.
7604
+ self._userSignal = strategyData.createSignal;
7605
+ self._closedSignal = strategyData.closedSignal;
7606
+ self._cancelledSignal = strategyData.cancelledSignal;
7607
+ self._activatedSignal = strategyData.activatedSignal;
7477
7608
  }
7478
7609
  // Restore pending signal
7479
7610
  const pendingSignal = await PersistSignalAdapter.readSignalData(self.params.execution.context.symbol, self.params.strategyName, self.params.exchangeName);
@@ -7491,6 +7622,14 @@ const WAIT_FOR_INIT_FN$4 = async (self) => {
7491
7622
  pendingSignal.minuteEstimatedTime = Infinity;
7492
7623
  }
7493
7624
  self._pendingSignal = pendingSignal;
7625
+ // Restore the commit queue only if the snapshot belongs to this exact pending
7626
+ // signal (pendingSignalId === restored id). The queue holds confirmed-but-not-yet
7627
+ // forwarded broker ops (average-buy / partial-* / trailing-* / breakeven) that are
7628
+ // always tied to the active position; a mismatch means the snapshot is stale and
7629
+ // the queue is dropped to avoid replaying ops against the wrong position.
7630
+ if (strategyData && strategyData.pendingSignalId === pendingSignal.id) {
7631
+ self._commitQueue = strategyData.commitQueue ?? [];
7632
+ }
7494
7633
  // Call onActive callback for restored signal
7495
7634
  const currentPrice = await self.params.exchange.getAveragePrice(self.params.execution.context.symbol);
7496
7635
  const currentTime = self.params.execution.context.when.getTime();
@@ -7540,6 +7679,8 @@ const PERSIST_STRATEGY_FN = async (self) => {
7540
7679
  return;
7541
7680
  }
7542
7681
  await PersistStrategyAdapter.writeStrategyData({
7682
+ pendingSignalId: self._pendingSignal?.id ?? null,
7683
+ createSignal: self._userSignal,
7543
7684
  commitQueue: self._commitQueue,
7544
7685
  closedSignal: self._closedSignal,
7545
7686
  cancelledSignal: self._cancelledSignal,
@@ -9675,6 +9816,13 @@ class ClientStrategy {
9675
9816
  this._cancelledSignal = null;
9676
9817
  this._closedSignal = null;
9677
9818
  this._activatedSignal = null;
9819
+ /**
9820
+ * User-supplied signal DTO to be consumed by the next GET_SIGNAL_FN tick instead of
9821
+ * params.getSignal. Set via createSignal. When non-null, params.getSignal is NOT called
9822
+ * and the existing pipeline (priceOpen decides pending vs scheduled, onSignalSync on open)
9823
+ * is reused.
9824
+ */
9825
+ this._userSignal = null;
9678
9826
  /** Queue for commit events to be processed in tick()/backtest() with proper timestamp */
9679
9827
  this._commitQueue = [];
9680
9828
  /**
@@ -11325,6 +11473,88 @@ class ClientStrategy {
11325
11473
  await PERSIST_STRATEGY_FN(this);
11326
11474
  // Commit will be emitted in tick() with correct currentTime
11327
11475
  }
11476
+ /**
11477
+ * Queues a user-supplied signal DTO to be consumed by the next tick instead of
11478
+ * params.getSignal.
11479
+ *
11480
+ * Works OUT of the async-hooks execution context (uses this.params.symbol directly).
11481
+ * priceOpen decides the outcome in the existing pipeline: when omitted the position opens
11482
+ * immediately at currentPrice; when provided the pipeline opens immediately if priceOpen is
11483
+ * already reached, otherwise registers a scheduled (priceOpen-awaiting) signal. On the next
11484
+ * tick GET_SIGNAL_FN consumes _userSignal and runs the normal pipeline, so onSignalSync
11485
+ * delivery is checked by OPEN_NEW_PENDING_SIGNAL_FN exactly as for a getSignal-produced signal.
11486
+ *
11487
+ * Validation (BEFORE any state mutation — a rejected call stores nothing):
11488
+ * - The DTO must pass the intrinsic shape/price checks.
11489
+ * - There must be NO signal already in flight: no active pending signal, no scheduled signal,
11490
+ * no already-queued createSignal, and no deferred activate/close/cancel awaiting drain.
11491
+ * Creating a new signal on top of an existing one is rejected.
11492
+ *
11493
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
11494
+ * @param currentPrice - Current market price (priceOpen fallback for immediate signals)
11495
+ * @param dto - Signal DTO to open (priceOpen optional)
11496
+ * @returns Promise that resolves when the DTO is queued (and persisted in live mode)
11497
+ * @throws {Error} If the DTO is invalid or a signal/deferred action is already in flight
11498
+ */
11499
+ async createSignal(symbol, currentPrice, dto) {
11500
+ this.params.logger.debug("ClientStrategy createSignal", { symbol, currentPrice });
11501
+ // Validate BEFORE mutating state — a bad DTO or a busy strategy must store nothing.
11502
+ // Reuse validateSignal (the canonical getSignal-output validator, branching
11503
+ // pending-vs-scheduled by the same rule as GET_SIGNAL_FN). It forwards to
11504
+ // validateCommonSignal which requires priceOpen and minuteEstimatedTime, so normalize the
11505
+ // DTO to the defaults GET_SIGNAL_FN would apply (priceOpen → currentPrice for an immediate
11506
+ // signal, minuteEstimatedTime → CC_MAX_SIGNAL_LIFETIME_MINUTES). The original dto stored in
11507
+ // _userSignal is left untouched so GET_SIGNAL_FN applies its own defaults on consume.
11508
+ if (!validateSignal({
11509
+ ...dto,
11510
+ priceOpen: dto.priceOpen ?? currentPrice,
11511
+ minuteEstimatedTime: dto.minuteEstimatedTime ?? GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES,
11512
+ }, currentPrice)) {
11513
+ throw new Error(`ClientStrategy createSignal: invalid signal DTO for symbol=${symbol}`);
11514
+ }
11515
+ // Reject if any signal is already in flight or any deferred action is awaiting drain.
11516
+ if (this._pendingSignal) {
11517
+ throw new Error(`ClientStrategy createSignal: a pending signal already exists for symbol=${symbol}`);
11518
+ }
11519
+ if (this._scheduledSignal) {
11520
+ throw new Error(`ClientStrategy createSignal: a scheduled signal already exists for symbol=${symbol}`);
11521
+ }
11522
+ if (this._userSignal) {
11523
+ throw new Error(`ClientStrategy createSignal: a signal is already queued for creation for symbol=${symbol}`);
11524
+ }
11525
+ if (this._activatedSignal) {
11526
+ throw new Error(`ClientStrategy createSignal: a scheduled activation is pending for symbol=${symbol}`);
11527
+ }
11528
+ if (this._closedSignal) {
11529
+ throw new Error(`ClientStrategy createSignal: a pending close is awaiting for symbol=${symbol}`);
11530
+ }
11531
+ if (this._cancelledSignal) {
11532
+ throw new Error(`ClientStrategy createSignal: a scheduled cancel is awaiting for symbol=${symbol}`);
11533
+ }
11534
+ this._userSignal = dto;
11535
+ await PERSIST_STRATEGY_FN(this);
11536
+ }
11537
+ /**
11538
+ * Returns the deferred strategy-state snapshot exactly as it would be written to persist on
11539
+ * this iteration: the in-memory _userSignal, _commitQueue and deferred user-action flags,
11540
+ * plus the current pending signal id.
11541
+ *
11542
+ * Synchronous in-memory read (no disk access), so it works OUT of the async-hooks context.
11543
+ *
11544
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
11545
+ * @returns The current StrategyData snapshot held in memory
11546
+ */
11547
+ async getStatus(symbol) {
11548
+ this.params.logger.debug("ClientStrategy getStatus", { symbol });
11549
+ return {
11550
+ pendingSignalId: this._pendingSignal?.id ?? null,
11551
+ createSignal: this._userSignal,
11552
+ commitQueue: this._commitQueue,
11553
+ closedSignal: this._closedSignal,
11554
+ cancelledSignal: this._cancelledSignal,
11555
+ activatedSignal: this._activatedSignal,
11556
+ };
11557
+ }
11328
11558
  /**
11329
11559
  * Validates preconditions for partialProfit without mutating state.
11330
11560
  *
@@ -12623,6 +12853,8 @@ class MergeRisk {
12623
12853
 
12624
12854
  /** Default interval for strategies that do not specify one */
12625
12855
  const STRATEGY_DEFAULT_INTERVAL = "1m";
12856
+ /** If not specified strategy will not open positions until createSignal is called manually */
12857
+ const STRATEGY_DEFAULT_SIGNAL = () => null;
12626
12858
  /**
12627
12859
  * If syncSubject listener or any registered action throws, it means the signal was not properly synchronized
12628
12860
  * to the exchange (e.g. limit order failed to fill).
@@ -13056,7 +13288,7 @@ class StrategyConnectionService {
13056
13288
  * @returns Configured ClientStrategy instance
13057
13289
  */
13058
13290
  this.getStrategy = functoolsKit.memoize(([symbol, strategyName, exchangeName, frameName, backtest]) => CREATE_KEY_FN$A(symbol, strategyName, exchangeName, frameName, backtest), (symbol, strategyName, exchangeName, frameName, backtest) => {
13059
- const { riskName = "", riskList = [], getSignal, interval = STRATEGY_DEFAULT_INTERVAL, callbacks, } = this.strategySchemaService.get(strategyName);
13291
+ const { riskName = "", riskList = [], getSignal = STRATEGY_DEFAULT_SIGNAL, interval = STRATEGY_DEFAULT_INTERVAL, callbacks, } = this.strategySchemaService.get(strategyName);
13060
13292
  return new ClientStrategy({
13061
13293
  symbol,
13062
13294
  interval,
@@ -14075,6 +14307,47 @@ class StrategyConnectionService {
14075
14307
  const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
14076
14308
  await strategy.closePending(symbol, backtest, payload);
14077
14309
  };
14310
+ /**
14311
+ * Queues a user-supplied signal DTO to be consumed by the next tick instead of getSignal.
14312
+ *
14313
+ * Delegates to ClientStrategy.createSignal(). Validated and rejected if a signal/deferred
14314
+ * action is already in flight. Works out of the async-hooks execution context.
14315
+ *
14316
+ * @param backtest - Whether running in backtest mode
14317
+ * @param symbol - Trading pair symbol
14318
+ * @param dto - Signal DTO to open
14319
+ * @param context - Context with strategyName, exchangeName, frameName
14320
+ * @returns Promise that resolves when the DTO is queued
14321
+ */
14322
+ this.createSignal = async (backtest, symbol, dto, context) => {
14323
+ this.loggerService.log("strategyConnectionService createSignal", {
14324
+ symbol,
14325
+ context,
14326
+ backtest,
14327
+ });
14328
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
14329
+ const currentPrice = await this.priceMetaService.getCurrentPrice(symbol, context, backtest);
14330
+ await strategy.createSignal(symbol, currentPrice, dto);
14331
+ };
14332
+ /**
14333
+ * Returns the in-memory deferred strategy-state snapshot for this iteration.
14334
+ *
14335
+ * Delegates to ClientStrategy.getStatus(). Synchronous in-memory read; works out of context.
14336
+ *
14337
+ * @param backtest - Whether running in backtest mode
14338
+ * @param symbol - Trading pair symbol
14339
+ * @param context - Context with strategyName, exchangeName, frameName
14340
+ * @returns Promise resolving to the current StrategyData snapshot
14341
+ */
14342
+ this.getStatus = async (backtest, symbol, context) => {
14343
+ this.loggerService.log("strategyConnectionService getStatus", {
14344
+ symbol,
14345
+ context,
14346
+ backtest,
14347
+ });
14348
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
14349
+ return await strategy.getStatus(symbol);
14350
+ };
14078
14351
  /**
14079
14352
  * Checks whether `partialProfit` would succeed without executing it.
14080
14353
  * Delegates to `ClientStrategy.validatePartialProfit()` — no throws, pure boolean result.
@@ -17678,6 +17951,47 @@ class StrategyCoreService {
17678
17951
  await this.validate(context);
17679
17952
  return await this.strategyConnectionService.closePending(backtest, symbol, context, payload);
17680
17953
  };
17954
+ /**
17955
+ * Queues a user-supplied signal DTO to be consumed by the next tick instead of getSignal.
17956
+ *
17957
+ * Validates the context, then delegates to StrategyConnectionService.createSignal().
17958
+ * Rejected if a signal or deferred action is already in flight. Does not require execution context.
17959
+ *
17960
+ * @param backtest - Whether running in backtest mode
17961
+ * @param symbol - Trading pair symbol
17962
+ * @param dto - Signal DTO to open
17963
+ * @param context - Context with strategyName, exchangeName, frameName
17964
+ * @returns Promise that resolves when the DTO is queued
17965
+ */
17966
+ this.createSignal = async (backtest, symbol, dto, context) => {
17967
+ this.loggerService.log("strategyCoreService createSignal", {
17968
+ symbol,
17969
+ context,
17970
+ backtest,
17971
+ });
17972
+ await this.validate(context);
17973
+ return await this.strategyConnectionService.createSignal(backtest, symbol, dto, context);
17974
+ };
17975
+ /**
17976
+ * Returns the in-memory deferred strategy-state snapshot for this iteration.
17977
+ *
17978
+ * Validates the context, then delegates to StrategyConnectionService.getStatus().
17979
+ * Does not require execution context.
17980
+ *
17981
+ * @param backtest - Whether running in backtest mode
17982
+ * @param symbol - Trading pair symbol
17983
+ * @param context - Context with strategyName, exchangeName, frameName
17984
+ * @returns Promise resolving to the current StrategyStatus snapshot
17985
+ */
17986
+ this.getStatus = async (backtest, symbol, context) => {
17987
+ this.loggerService.log("strategyCoreService getStatus", {
17988
+ symbol,
17989
+ context,
17990
+ backtest,
17991
+ });
17992
+ await this.validate(context);
17993
+ return await this.strategyConnectionService.getStatus(backtest, symbol, context);
17994
+ };
17681
17995
  /**
17682
17996
  * Disposes the ClientStrategy instance for the given context.
17683
17997
  *
@@ -19202,7 +19516,7 @@ class StrategySchemaService {
19202
19516
  if (strategySchema.interval && typeof strategySchema.interval !== "string") {
19203
19517
  throw new Error(`strategy schema validation failed: invalid interval for strategyName=${strategySchema.strategyName}`);
19204
19518
  }
19205
- if (typeof strategySchema.getSignal !== "function") {
19519
+ if (strategySchema.getSignal && typeof strategySchema.getSignal !== "function") {
19206
19520
  throw new Error(`strategy schema validation failed: missing getSignal for strategyName=${strategySchema.strategyName}`);
19207
19521
  }
19208
19522
  };
@@ -42916,6 +43230,8 @@ const GET_POSITION_PARTIAL_OVERLAP_METHOD_NAME = "strategy.getPositionPartialOve
42916
43230
  const HAS_NO_PENDING_SIGNAL_METHOD_NAME = "strategy.hasNoPendingSignal";
42917
43231
  const HAS_NO_SCHEDULED_SIGNAL_METHOD_NAME = "strategy.hasNoScheduledSignal";
42918
43232
  const COMMIT_SIGNAL_NOTIFY_METHOD_NAME = "strategy.commitSignalNotify";
43233
+ const COMMIT_CREATE_SIGNAL_METHOD_NAME = "strategy.commitCreateSignal";
43234
+ const GET_STRATEGY_STATUS_METHOD_NAME = "strategy.getStrategyStatus";
42919
43235
  /**
42920
43236
  * Cancels the scheduled signal without stopping the strategy.
42921
43237
  *
@@ -44980,6 +45296,69 @@ async function commitSignalNotify(symbol, payload = {}) {
44980
45296
  const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
44981
45297
  await bt.notificationHelperService.commitSignalNotify(payload, symbol, currentPrice, { strategyName, exchangeName, frameName }, isBacktest);
44982
45298
  }
45299
+ /**
45300
+ * Queues a user-supplied signal DTO to be consumed by the next tick instead of params.getSignal.
45301
+ *
45302
+ * priceOpen decides the outcome via the existing pipeline: omitted → opens immediately at the
45303
+ * current price; provided → opens immediately if already reached, otherwise registers a
45304
+ * scheduled (priceOpen-awaiting) signal. The DTO is validated (reusing validateSignal) and the
45305
+ * call is rejected if a signal or deferred action is already in flight.
45306
+ *
45307
+ * Automatically detects backtest/live mode from execution context.
45308
+ *
45309
+ * @param symbol - Trading pair symbol
45310
+ * @param dto - Signal DTO to open (priceOpen optional)
45311
+ * @returns Promise that resolves when the DTO is queued
45312
+ *
45313
+ * @example
45314
+ * ```typescript
45315
+ * import { createSignal } from "backtest-kit";
45316
+ *
45317
+ * // Open immediately at current price
45318
+ * await commitCreateSignal("BTCUSDT", { position: "long", priceTakeProfit: 110, priceStopLoss: 90 });
45319
+ * ```
45320
+ */
45321
+ async function commitCreateSignal(symbol, dto) {
45322
+ bt.loggerService.info(COMMIT_CREATE_SIGNAL_METHOD_NAME, { symbol });
45323
+ if (!ExecutionContextService.hasContext()) {
45324
+ throw new Error("createSignal requires an execution context");
45325
+ }
45326
+ if (!MethodContextService.hasContext()) {
45327
+ throw new Error("createSignal requires a method context");
45328
+ }
45329
+ const { backtest: isBacktest } = bt.executionContextService.context;
45330
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
45331
+ await bt.strategyCoreService.createSignal(isBacktest, symbol, dto, { exchangeName, frameName, strategyName });
45332
+ }
45333
+ /**
45334
+ * Returns the in-memory deferred strategy-state snapshot for the current iteration: the queued
45335
+ * createSignal, commit queue and deferred user-action flags, plus the current pending signal id.
45336
+ *
45337
+ * Automatically detects backtest/live mode from execution context.
45338
+ *
45339
+ * @param symbol - Trading pair symbol
45340
+ * @returns Promise resolving to the current StrategyStatus snapshot
45341
+ *
45342
+ * @example
45343
+ * ```typescript
45344
+ * import { getStrategyStatus } from "backtest-kit";
45345
+ *
45346
+ * const status = await getStrategyStatus("BTCUSDT");
45347
+ * console.log(status.pendingSignalId, status.commitQueue.length);
45348
+ * ```
45349
+ */
45350
+ async function getStrategyStatus(symbol) {
45351
+ bt.loggerService.info(GET_STRATEGY_STATUS_METHOD_NAME, { symbol });
45352
+ if (!ExecutionContextService.hasContext()) {
45353
+ throw new Error("getStrategyStatus requires an execution context");
45354
+ }
45355
+ if (!MethodContextService.hasContext()) {
45356
+ throw new Error("getStrategyStatus requires a method context");
45357
+ }
45358
+ const { backtest: isBacktest } = bt.executionContextService.context;
45359
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
45360
+ return await bt.strategyCoreService.getStatus(isBacktest, symbol, { exchangeName, frameName, strategyName });
45361
+ }
44983
45362
 
44984
45363
  const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
44985
45364
  /**
@@ -46651,6 +47030,8 @@ const BACKTEST_METHOD_NAME_GET_POSITION_PARTIAL_OVERLAP = "BacktestUtils.getPosi
46651
47030
  const BACKTEST_METHOD_NAME_BREAKEVEN = "Backtest.commitBreakeven";
46652
47031
  const BACKTEST_METHOD_NAME_CANCEL_SCHEDULED = "Backtest.commitCancelScheduled";
46653
47032
  const BACKTEST_METHOD_NAME_CLOSE_PENDING = "Backtest.commitClosePending";
47033
+ const BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL = "Backtest.commitCreateSignal";
47034
+ const BACKTEST_METHOD_NAME_GET_STRATEGY_STATUS = "Backtest.getStrategyStatus";
46654
47035
  const BACKTEST_METHOD_NAME_PARTIAL_PROFIT = "BacktestUtils.commitPartialProfit";
46655
47036
  const BACKTEST_METHOD_NAME_PARTIAL_LOSS = "BacktestUtils.commitPartialLoss";
46656
47037
  const BACKTEST_METHOD_NAME_PARTIAL_PROFIT_COST = "BacktestUtils.commitPartialProfitCost";
@@ -48355,6 +48736,53 @@ class BacktestUtils {
48355
48736
  }
48356
48737
  await bt.strategyCoreService.closePending(true, symbol, context, payload);
48357
48738
  };
48739
+ /**
48740
+ * Queues a user-supplied signal DTO to be consumed by the next backtest tick instead of
48741
+ * params.getSignal.
48742
+ *
48743
+ * priceOpen decides the outcome via the existing pipeline: omitted → opens immediately at the
48744
+ * current price; provided → opens immediately if already reached, otherwise registers a
48745
+ * scheduled signal. Validated, and rejected if a signal or deferred action is already in flight.
48746
+ *
48747
+ * @param symbol - Trading pair symbol
48748
+ * @param context - Execution context with strategyName, exchangeName, and frameName
48749
+ * @param dto - Signal DTO to open (priceOpen optional)
48750
+ * @returns Promise that resolves when the DTO is queued
48751
+ */
48752
+ this.commitCreateSignal = async (symbol, context, dto) => {
48753
+ bt.loggerService.info(BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL, {
48754
+ symbol,
48755
+ context,
48756
+ });
48757
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL);
48758
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL);
48759
+ {
48760
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
48761
+ riskName &&
48762
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL);
48763
+ riskList &&
48764
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL));
48765
+ actions &&
48766
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_COMMIT_CREATE_SIGNAL));
48767
+ }
48768
+ await bt.strategyCoreService.createSignal(true, symbol, dto, context);
48769
+ };
48770
+ /**
48771
+ * Returns the in-memory deferred strategy-state snapshot for the current backtest iteration.
48772
+ *
48773
+ * @param symbol - Trading pair symbol
48774
+ * @param context - Execution context with strategyName, exchangeName, and frameName
48775
+ * @returns Promise resolving to the current StrategyStatus snapshot
48776
+ */
48777
+ this.getStrategyStatus = async (symbol, context) => {
48778
+ bt.loggerService.info(BACKTEST_METHOD_NAME_GET_STRATEGY_STATUS, {
48779
+ symbol,
48780
+ context,
48781
+ });
48782
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_GET_STRATEGY_STATUS);
48783
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_GET_STRATEGY_STATUS);
48784
+ return await bt.strategyCoreService.getStatus(true, symbol, context);
48785
+ };
48358
48786
  /**
48359
48787
  * Executes partial close at profit level (moving toward TP).
48360
48788
  *
@@ -49313,6 +49741,8 @@ const LIVE_METHOD_NAME_GET_POSITION_PARTIAL_OVERLAP = "LiveUtils.getPositionPart
49313
49741
  const LIVE_METHOD_NAME_BREAKEVEN = "Live.commitBreakeven";
49314
49742
  const LIVE_METHOD_NAME_CANCEL_SCHEDULED = "Live.cancelScheduled";
49315
49743
  const LIVE_METHOD_NAME_CLOSE_PENDING = "Live.closePending";
49744
+ const LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL = "Live.commitCreateSignal";
49745
+ const LIVE_METHOD_NAME_GET_STRATEGY_STATUS = "Live.getStrategyStatus";
49316
49746
  const LIVE_METHOD_NAME_PARTIAL_PROFIT = "LiveUtils.commitPartialProfit";
49317
49747
  const LIVE_METHOD_NAME_PARTIAL_LOSS = "LiveUtils.commitPartialLoss";
49318
49748
  const LIVE_METHOD_NAME_PARTIAL_PROFIT_COST = "LiveUtils.commitPartialProfitCost";
@@ -51190,6 +51620,61 @@ class LiveUtils {
51190
51620
  frameName: "",
51191
51621
  }, payload);
51192
51622
  };
51623
+ /**
51624
+ * Queues a user-supplied signal DTO to be consumed by the next live tick instead of
51625
+ * params.getSignal.
51626
+ *
51627
+ * priceOpen decides the outcome via the existing pipeline: omitted → opens immediately at the
51628
+ * current price; provided → opens immediately if already reached, otherwise registers a
51629
+ * scheduled signal. Validated, and rejected if a signal or deferred action is already in flight.
51630
+ *
51631
+ * @param symbol - Trading pair symbol
51632
+ * @param context - Execution context with strategyName and exchangeName
51633
+ * @param dto - Signal DTO to open (priceOpen optional)
51634
+ * @returns Promise that resolves when the DTO is queued
51635
+ */
51636
+ this.commitCreateSignal = async (symbol, context, dto) => {
51637
+ bt.loggerService.info(LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL, {
51638
+ symbol,
51639
+ context,
51640
+ });
51641
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL);
51642
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL);
51643
+ {
51644
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
51645
+ riskName &&
51646
+ bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL);
51647
+ riskList &&
51648
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL));
51649
+ actions &&
51650
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_COMMIT_CREATE_SIGNAL));
51651
+ }
51652
+ await bt.strategyCoreService.createSignal(false, symbol, dto, {
51653
+ strategyName: context.strategyName,
51654
+ exchangeName: context.exchangeName,
51655
+ frameName: "",
51656
+ });
51657
+ };
51658
+ /**
51659
+ * Returns the in-memory deferred strategy-state snapshot for the current live iteration.
51660
+ *
51661
+ * @param symbol - Trading pair symbol
51662
+ * @param context - Execution context with strategyName and exchangeName
51663
+ * @returns Promise resolving to the current StrategyStatus snapshot
51664
+ */
51665
+ this.getStrategyStatus = async (symbol, context) => {
51666
+ bt.loggerService.info(LIVE_METHOD_NAME_GET_STRATEGY_STATUS, {
51667
+ symbol,
51668
+ context,
51669
+ });
51670
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_GET_STRATEGY_STATUS);
51671
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_GET_STRATEGY_STATUS);
51672
+ return await bt.strategyCoreService.getStatus(false, symbol, {
51673
+ strategyName: context.strategyName,
51674
+ exchangeName: context.exchangeName,
51675
+ frameName: "",
51676
+ });
51677
+ };
51193
51678
  /**
51194
51679
  * Executes partial close at profit level (moving toward TP).
51195
51680
  *
@@ -67836,119 +68321,6 @@ const percentValue = (yesterdayValue, todayValue) => {
67836
68321
  return yesterdayValue / todayValue - 1;
67837
68322
  };
67838
68323
 
67839
- /**
67840
- * Validates ISignalDto returned by getSignal, branching on the same logic as ClientStrategy GET_SIGNAL_FN.
67841
- *
67842
- * When priceOpen is provided:
67843
- * - If currentPrice already reached priceOpen (shouldActivateImmediately) →
67844
- * validates as pending: currentPrice must be between SL and TP
67845
- * - Otherwise → validates as scheduled: priceOpen must be between SL and TP
67846
- *
67847
- * When priceOpen is absent:
67848
- * - Validates as pending: currentPrice must be between SL and TP
67849
- *
67850
- * Checks:
67851
- * - currentPrice is a finite positive number
67852
- * - Common signal fields via validateCommonSignal (position, prices, TP/SL relationships, minuteEstimatedTime)
67853
- * - Position-specific immediate-close protection (pending) or activation-close protection (scheduled)
67854
- *
67855
- * @param signal - Signal DTO returned by getSignal
67856
- * @param currentPrice - Current market price at the moment of signal creation
67857
- * @returns true if signal is valid, false if validation errors were found (errors logged to console.error)
67858
- */
67859
- const validateSignal = (signal, currentPrice) => {
67860
- const errors = [];
67861
- // ЗАЩИТА ОТ NaN/Infinity: currentPrice должна быть конечным числом
67862
- {
67863
- if (typeof currentPrice !== "number") {
67864
- errors.push(`currentPrice must be a number type, got ${currentPrice} (${typeof currentPrice})`);
67865
- }
67866
- if (!isFinite(currentPrice)) {
67867
- errors.push(`currentPrice must be a finite number, got ${currentPrice} (${typeof currentPrice})`);
67868
- }
67869
- if (isFinite(currentPrice) && currentPrice <= 0) {
67870
- errors.push(`currentPrice must be positive, got ${currentPrice}`);
67871
- }
67872
- }
67873
- if (errors.length > 0) {
67874
- console.error(`Invalid signal for ${signal.position} position (${signal.symbol || "empty symbol"}):\n${errors.join("\n")}`);
67875
- return false;
67876
- }
67877
- try {
67878
- validateCommonSignal(signal);
67879
- }
67880
- catch (error) {
67881
- console.error(functoolsKit.getErrorMessage(error));
67882
- return false;
67883
- }
67884
- // Определяем режим валидации по той же логике что в GET_SIGNAL_FN:
67885
- // - нет priceOpen → pending (открывается по currentPrice)
67886
- // - priceOpen задан и уже достигнут (shouldActivateImmediately) → pending
67887
- // - priceOpen задан и ещё не достигнут → scheduled
67888
- const hasPriceOpen = signal.priceOpen !== undefined;
67889
- const shouldActivateImmediately = hasPriceOpen && ((signal.position === "long" && currentPrice <= signal.priceOpen) ||
67890
- (signal.position === "short" && currentPrice >= signal.priceOpen));
67891
- const isScheduled = hasPriceOpen && !shouldActivateImmediately;
67892
- if (isScheduled) {
67893
- // Scheduled: priceOpen должен быть между SL и TP (активация не даст моментального закрытия)
67894
- if (signal.position === "long") {
67895
- if (isFinite(signal.priceOpen)) {
67896
- if (signal.priceOpen <= signal.priceStopLoss) {
67897
- errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
67898
- `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
67899
- }
67900
- if (signal.priceOpen >= signal.priceTakeProfit) {
67901
- errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
67902
- `Signal would close immediately on activation. This is logically impossible for LONG position.`);
67903
- }
67904
- }
67905
- }
67906
- if (signal.position === "short") {
67907
- if (isFinite(signal.priceOpen)) {
67908
- if (signal.priceOpen >= signal.priceStopLoss) {
67909
- errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
67910
- `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
67911
- }
67912
- if (signal.priceOpen <= signal.priceTakeProfit) {
67913
- errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
67914
- `Signal would close immediately on activation. This is logically impossible for SHORT position.`);
67915
- }
67916
- }
67917
- }
67918
- }
67919
- else {
67920
- // Pending: currentPrice должна быть между SL и TP (позиция не закроется сразу после открытия)
67921
- if (signal.position === "long") {
67922
- if (isFinite(currentPrice)) {
67923
- if (currentPrice <= signal.priceStopLoss) {
67924
- errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
67925
- `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
67926
- }
67927
- if (currentPrice >= signal.priceTakeProfit) {
67928
- errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
67929
- `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
67930
- }
67931
- }
67932
- }
67933
- if (signal.position === "short") {
67934
- if (isFinite(currentPrice)) {
67935
- if (currentPrice >= signal.priceStopLoss) {
67936
- errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
67937
- `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
67938
- }
67939
- if (currentPrice <= signal.priceTakeProfit) {
67940
- errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
67941
- `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
67942
- }
67943
- }
67944
- }
67945
- }
67946
- if (errors.length > 0) {
67947
- console.error(`Invalid signal for ${signal.position} position (${signal.symbol || "empty symbol"}):\n${errors.join("\n")}`);
67948
- }
67949
- return !errors.length;
67950
- };
67951
-
67952
68324
  exports.ActionBase = ActionBase;
67953
68325
  exports.Backtest = Backtest;
67954
68326
  exports.Breakeven = Breakeven;
@@ -68058,6 +68430,7 @@ exports.commitAverageBuy = commitAverageBuy;
68058
68430
  exports.commitBreakeven = commitBreakeven;
68059
68431
  exports.commitCancelScheduled = commitCancelScheduled;
68060
68432
  exports.commitClosePending = commitClosePending;
68433
+ exports.commitCreateSignal = commitCreateSignal;
68061
68434
  exports.commitPartialLoss = commitPartialLoss;
68062
68435
  exports.commitPartialLossCost = commitPartialLossCost;
68063
68436
  exports.commitPartialProfit = commitPartialProfit;
@@ -68141,6 +68514,7 @@ exports.getSessionData = getSessionData;
68141
68514
  exports.getSignalState = getSignalState;
68142
68515
  exports.getSizingSchema = getSizingSchema;
68143
68516
  exports.getStrategySchema = getStrategySchema;
68517
+ exports.getStrategyStatus = getStrategyStatus;
68144
68518
  exports.getSymbol = getSymbol;
68145
68519
  exports.getTimestamp = getTimestamp;
68146
68520
  exports.getTotalClosed = getTotalClosed;