backtest-kit 5.9.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/build/index.cjs +812 -412
- package/build/index.mjs +809 -413
- package/package.json +1 -1
- package/types.d.ts +117 -10
package/build/index.cjs
CHANGED
|
@@ -575,6 +575,16 @@ const GLOBAL_CONFIG = {
|
|
|
575
575
|
* Default: false (PPPL logic is only applied when it does not break the direction of exits, ensuring clearer profit/loss outcomes)
|
|
576
576
|
*/
|
|
577
577
|
CC_ENABLE_PPPL_EVERYWHERE: false,
|
|
578
|
+
/**
|
|
579
|
+
* Enables trailing logic (Trailing Take / Trailing Stop) without requiring absorption conditions.
|
|
580
|
+
* Allows trailing mechanisms to be activated regardless of whether absorption has been detected.
|
|
581
|
+
*
|
|
582
|
+
* This can lead to earlier or more frequent trailing activation, improving reactivity to price movement,
|
|
583
|
+
* but may increase sensitivity to noise and result in premature exits.
|
|
584
|
+
*
|
|
585
|
+
* Default: false (trailing logic is applied only when absorption conditions are met)
|
|
586
|
+
*/
|
|
587
|
+
CC_ENABLE_TRAILING_EVERYWHERE: false,
|
|
578
588
|
/**
|
|
579
589
|
* Cost of entering a position (in USD).
|
|
580
590
|
* This is used as a default value for calculating position size and risk management when cost data is not provided by the strategy
|
|
@@ -889,6 +899,8 @@ const INTERVAL_MINUTES$8 = {
|
|
|
889
899
|
"4h": 240,
|
|
890
900
|
"6h": 360,
|
|
891
901
|
"8h": 480,
|
|
902
|
+
"1d": 1440,
|
|
903
|
+
"1w": 10080,
|
|
892
904
|
};
|
|
893
905
|
const MS_PER_MINUTE$6 = 60000;
|
|
894
906
|
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
|
|
@@ -2642,6 +2654,8 @@ const INTERVAL_MINUTES$7 = {
|
|
|
2642
2654
|
"4h": 240,
|
|
2643
2655
|
"6h": 360,
|
|
2644
2656
|
"8h": 480,
|
|
2657
|
+
"1d": 1440,
|
|
2658
|
+
"1w": 10080,
|
|
2645
2659
|
};
|
|
2646
2660
|
/**
|
|
2647
2661
|
* Aligns timestamp down to the nearest interval boundary.
|
|
@@ -3928,6 +3942,390 @@ const beginTime = (run) => (...args) => {
|
|
|
3928
3942
|
return fn();
|
|
3929
3943
|
};
|
|
3930
3944
|
|
|
3945
|
+
/**
|
|
3946
|
+
* Validates the common fields of ISignalDto that apply to both pending and scheduled signals.
|
|
3947
|
+
*
|
|
3948
|
+
* Checks:
|
|
3949
|
+
* - position is "long" or "short"
|
|
3950
|
+
* - priceOpen, priceTakeProfit, priceStopLoss are finite positive numbers
|
|
3951
|
+
* - price relationships are correct for position direction (TP/SL on correct sides of priceOpen)
|
|
3952
|
+
* - TP/SL distance constraints from GLOBAL_CONFIG
|
|
3953
|
+
* - minuteEstimatedTime is valid
|
|
3954
|
+
*
|
|
3955
|
+
* Does NOT check:
|
|
3956
|
+
* - currentPrice vs SL/TP (immediate close protection — handled by pending/scheduled validators)
|
|
3957
|
+
* - ISignalRow-specific fields: id, exchangeName, strategyName, symbol, _isScheduled, scheduledAt, pendingAt
|
|
3958
|
+
*
|
|
3959
|
+
* @deprecated This is an internal code exported for unit tests only. Use `validateSignal` in Strategy::getSignal
|
|
3960
|
+
*
|
|
3961
|
+
* @param signal - Signal DTO to validate
|
|
3962
|
+
* @returns Array of error strings (empty if valid)
|
|
3963
|
+
*/
|
|
3964
|
+
const validateCommonSignal = (signal) => {
|
|
3965
|
+
const errors = [];
|
|
3966
|
+
// Валидация position
|
|
3967
|
+
{
|
|
3968
|
+
if (signal.position === undefined || signal.position === null) {
|
|
3969
|
+
errors.push('position is required and must be "long" or "short"');
|
|
3970
|
+
}
|
|
3971
|
+
if (signal.position !== "long" && signal.position !== "short") {
|
|
3972
|
+
errors.push(`position must be "long" or "short", got "${signal.position}"`);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
// ЗАЩИТА ОТ NaN/Infinity: все цены должны быть конечными числами
|
|
3976
|
+
{
|
|
3977
|
+
if (typeof signal.priceOpen !== "number") {
|
|
3978
|
+
errors.push(`priceOpen must be a number type, got ${signal.priceOpen} (${typeof signal.priceOpen})`);
|
|
3979
|
+
}
|
|
3980
|
+
if (!isFinite(signal.priceOpen)) {
|
|
3981
|
+
errors.push(`priceOpen must be a finite number, got ${signal.priceOpen} (${typeof signal.priceOpen})`);
|
|
3982
|
+
}
|
|
3983
|
+
if (typeof signal.priceTakeProfit !== "number") {
|
|
3984
|
+
errors.push(`priceTakeProfit must be a number type, got ${signal.priceTakeProfit} (${typeof signal.priceTakeProfit})`);
|
|
3985
|
+
}
|
|
3986
|
+
if (!isFinite(signal.priceTakeProfit)) {
|
|
3987
|
+
errors.push(`priceTakeProfit must be a finite number, got ${signal.priceTakeProfit} (${typeof signal.priceTakeProfit})`);
|
|
3988
|
+
}
|
|
3989
|
+
if (typeof signal.priceStopLoss !== "number") {
|
|
3990
|
+
errors.push(`priceStopLoss must be a number type, got ${signal.priceStopLoss} (${typeof signal.priceStopLoss})`);
|
|
3991
|
+
}
|
|
3992
|
+
if (!isFinite(signal.priceStopLoss)) {
|
|
3993
|
+
errors.push(`priceStopLoss must be a finite number, got ${signal.priceStopLoss} (${typeof signal.priceStopLoss})`);
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
// Валидация цен (только если они конечные)
|
|
3997
|
+
{
|
|
3998
|
+
if (isFinite(signal.priceOpen) && signal.priceOpen <= 0) {
|
|
3999
|
+
errors.push(`priceOpen must be positive, got ${signal.priceOpen}`);
|
|
4000
|
+
}
|
|
4001
|
+
if (isFinite(signal.priceTakeProfit) && signal.priceTakeProfit <= 0) {
|
|
4002
|
+
errors.push(`priceTakeProfit must be positive, got ${signal.priceTakeProfit}`);
|
|
4003
|
+
}
|
|
4004
|
+
if (isFinite(signal.priceStopLoss) && signal.priceStopLoss <= 0) {
|
|
4005
|
+
errors.push(`priceStopLoss must be positive, got ${signal.priceStopLoss}`);
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
// Валидация для long позиции
|
|
4009
|
+
if (signal.position === "long") {
|
|
4010
|
+
// Проверка соотношения цен для long
|
|
4011
|
+
{
|
|
4012
|
+
if (signal.priceTakeProfit <= signal.priceOpen) {
|
|
4013
|
+
errors.push(`Long: priceTakeProfit (${signal.priceTakeProfit}) must be > priceOpen (${signal.priceOpen})`);
|
|
4014
|
+
}
|
|
4015
|
+
if (signal.priceStopLoss >= signal.priceOpen) {
|
|
4016
|
+
errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
// ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
|
|
4020
|
+
{
|
|
4021
|
+
if (GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4022
|
+
const tpDistancePercent = ((signal.priceTakeProfit - signal.priceOpen) / signal.priceOpen) * 100;
|
|
4023
|
+
if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4024
|
+
errors.push(`Long: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
|
|
4025
|
+
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
|
|
4026
|
+
`Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
// ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
|
|
4031
|
+
{
|
|
4032
|
+
if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4033
|
+
const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
|
|
4034
|
+
if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4035
|
+
errors.push(`Long: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4036
|
+
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
|
|
4037
|
+
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
// ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
|
|
4042
|
+
{
|
|
4043
|
+
if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4044
|
+
const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
|
|
4045
|
+
if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4046
|
+
errors.push(`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4047
|
+
`Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
|
|
4048
|
+
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
// Валидация для short позиции
|
|
4054
|
+
if (signal.position === "short") {
|
|
4055
|
+
// Проверка соотношения цен для short
|
|
4056
|
+
{
|
|
4057
|
+
if (signal.priceTakeProfit >= signal.priceOpen) {
|
|
4058
|
+
errors.push(`Short: priceTakeProfit (${signal.priceTakeProfit}) must be < priceOpen (${signal.priceOpen})`);
|
|
4059
|
+
}
|
|
4060
|
+
if (signal.priceStopLoss <= signal.priceOpen) {
|
|
4061
|
+
errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
// ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
|
|
4065
|
+
{
|
|
4066
|
+
if (GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4067
|
+
const tpDistancePercent = ((signal.priceOpen - signal.priceTakeProfit) / signal.priceOpen) * 100;
|
|
4068
|
+
if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4069
|
+
errors.push(`Short: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
|
|
4070
|
+
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
|
|
4071
|
+
`Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
4075
|
+
// ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
|
|
4076
|
+
{
|
|
4077
|
+
if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4078
|
+
const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
|
|
4079
|
+
if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4080
|
+
errors.push(`Short: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4081
|
+
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
|
|
4082
|
+
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
// ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
|
|
4087
|
+
{
|
|
4088
|
+
if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4089
|
+
const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
|
|
4090
|
+
if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4091
|
+
errors.push(`Short: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4092
|
+
`Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
|
|
4093
|
+
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
// Валидация временных параметров
|
|
4099
|
+
{
|
|
4100
|
+
if (typeof signal.minuteEstimatedTime !== "number") {
|
|
4101
|
+
errors.push(`minuteEstimatedTime must be a number type, got ${signal.minuteEstimatedTime} (${typeof signal.minuteEstimatedTime})`);
|
|
4102
|
+
}
|
|
4103
|
+
if (signal.minuteEstimatedTime <= 0) {
|
|
4104
|
+
errors.push(`minuteEstimatedTime must be positive, got ${signal.minuteEstimatedTime}`);
|
|
4105
|
+
}
|
|
4106
|
+
if (signal.minuteEstimatedTime === Infinity && GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES !== Infinity) {
|
|
4107
|
+
errors.push(`minuteEstimatedTime cannot be Infinity when CC_MAX_SIGNAL_LIFETIME_MINUTES is not Infinity`);
|
|
4108
|
+
}
|
|
4109
|
+
if (signal.minuteEstimatedTime !== Infinity && !Number.isInteger(signal.minuteEstimatedTime)) {
|
|
4110
|
+
errors.push(`minuteEstimatedTime must be an integer (whole number), got ${signal.minuteEstimatedTime}`);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
// ЗАЩИТА ОТ ВЕЧНЫХ СИГНАЛОВ: ограничиваем максимальное время жизни сигнала
|
|
4114
|
+
{
|
|
4115
|
+
if (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES !== Infinity && signal.minuteEstimatedTime > GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES) {
|
|
4116
|
+
const days = (signal.minuteEstimatedTime / 60 / 24).toFixed(1);
|
|
4117
|
+
const maxDays = (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES / 60 / 24).toFixed(0);
|
|
4118
|
+
errors.push(`minuteEstimatedTime too large (${signal.minuteEstimatedTime} minutes = ${days} days). ` +
|
|
4119
|
+
`Maximum: ${GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES} minutes (${maxDays} days) to prevent strategy deadlock. ` +
|
|
4120
|
+
`Eternal signals block risk limits and prevent new trades.`);
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
// Кидаем ошибку если есть проблемы
|
|
4124
|
+
if (errors.length > 0) {
|
|
4125
|
+
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
4126
|
+
}
|
|
4127
|
+
};
|
|
4128
|
+
|
|
4129
|
+
/**
|
|
4130
|
+
* Validates a pending (immediately active) signal before it is opened.
|
|
4131
|
+
*
|
|
4132
|
+
* Checks:
|
|
4133
|
+
* - ISignalRow-specific fields: id, exchangeName, strategyName, symbol, _isScheduled
|
|
4134
|
+
* - currentPrice is a finite positive number
|
|
4135
|
+
* - Common signal fields via validateCommonSignal (position, prices, TP/SL relationships, minuteEstimatedTime)
|
|
4136
|
+
* - currentPrice is between SL and TP — position would not be immediately closed on open
|
|
4137
|
+
* - scheduledAt and pendingAt are positive numbers
|
|
4138
|
+
*
|
|
4139
|
+
* @deprecated This is an internal code exported for unit tests only. Use `validateSignal` in Strategy::getSignal
|
|
4140
|
+
*
|
|
4141
|
+
* @param signal - Pending signal row to validate
|
|
4142
|
+
* @param currentPrice - Current market price at the moment of signal creation
|
|
4143
|
+
* @throws {Error} If any validation check fails
|
|
4144
|
+
*/
|
|
4145
|
+
const validatePendingSignal = (signal, currentPrice) => {
|
|
4146
|
+
const errors = [];
|
|
4147
|
+
// ПРОВЕРКА ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ ISignalRow
|
|
4148
|
+
{
|
|
4149
|
+
if (signal.id === undefined || signal.id === null || signal.id === '') {
|
|
4150
|
+
errors.push('id is required and must be a non-empty string');
|
|
4151
|
+
}
|
|
4152
|
+
if (signal.exchangeName === undefined || signal.exchangeName === null || signal.exchangeName === '') {
|
|
4153
|
+
errors.push('exchangeName is required');
|
|
4154
|
+
}
|
|
4155
|
+
if (signal.strategyName === undefined || signal.strategyName === null || signal.strategyName === '') {
|
|
4156
|
+
errors.push('strategyName is required');
|
|
4157
|
+
}
|
|
4158
|
+
if (signal.symbol === undefined || signal.symbol === null || signal.symbol === '') {
|
|
4159
|
+
errors.push('symbol is required and must be a non-empty string');
|
|
4160
|
+
}
|
|
4161
|
+
if (signal._isScheduled === undefined || signal._isScheduled === null) {
|
|
4162
|
+
errors.push('_isScheduled is required');
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
// ЗАЩИТА ОТ NaN/Infinity: currentPrice должна быть конечным числом
|
|
4166
|
+
{
|
|
4167
|
+
if (typeof currentPrice !== "number") {
|
|
4168
|
+
errors.push(`currentPrice must be a number type, got ${currentPrice} (${typeof currentPrice})`);
|
|
4169
|
+
}
|
|
4170
|
+
if (!isFinite(currentPrice)) {
|
|
4171
|
+
errors.push(`currentPrice must be a finite number, got ${currentPrice} (${typeof currentPrice})`);
|
|
4172
|
+
}
|
|
4173
|
+
if (isFinite(currentPrice) && currentPrice <= 0) {
|
|
4174
|
+
errors.push(`currentPrice must be positive, got ${currentPrice}`);
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
if (errors.length > 0) {
|
|
4178
|
+
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
4179
|
+
}
|
|
4180
|
+
validateCommonSignal(signal);
|
|
4181
|
+
// ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
|
|
4182
|
+
if (signal.position === "long") {
|
|
4183
|
+
if (isFinite(currentPrice)) {
|
|
4184
|
+
// LONG: currentPrice должна быть МЕЖДУ SL и TP (не пробита ни одна граница)
|
|
4185
|
+
// SL < currentPrice < TP
|
|
4186
|
+
if (currentPrice <= signal.priceStopLoss) {
|
|
4187
|
+
errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4188
|
+
`Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
|
|
4189
|
+
}
|
|
4190
|
+
if (currentPrice >= signal.priceTakeProfit) {
|
|
4191
|
+
errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4192
|
+
`Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
if (signal.position === "short") {
|
|
4197
|
+
if (isFinite(currentPrice)) {
|
|
4198
|
+
// SHORT: currentPrice должна быть МЕЖДУ TP и SL (не пробита ни одна граница)
|
|
4199
|
+
// TP < currentPrice < SL
|
|
4200
|
+
if (currentPrice >= signal.priceStopLoss) {
|
|
4201
|
+
errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4202
|
+
`Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
|
|
4203
|
+
}
|
|
4204
|
+
if (currentPrice <= signal.priceTakeProfit) {
|
|
4205
|
+
errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4206
|
+
`Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
// Валидация временных меток
|
|
4211
|
+
{
|
|
4212
|
+
if (typeof signal.scheduledAt !== "number") {
|
|
4213
|
+
errors.push(`scheduledAt must be a number type, got ${signal.scheduledAt} (${typeof signal.scheduledAt})`);
|
|
4214
|
+
}
|
|
4215
|
+
if (signal.scheduledAt <= 0) {
|
|
4216
|
+
errors.push(`scheduledAt must be positive, got ${signal.scheduledAt}`);
|
|
4217
|
+
}
|
|
4218
|
+
if (typeof signal.pendingAt !== "number") {
|
|
4219
|
+
errors.push(`pendingAt must be a number type, got ${signal.pendingAt} (${typeof signal.pendingAt})`);
|
|
4220
|
+
}
|
|
4221
|
+
if (signal.pendingAt <= 0) {
|
|
4222
|
+
errors.push(`pendingAt must be positive, got ${signal.pendingAt}`);
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
if (errors.length > 0) {
|
|
4226
|
+
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
4227
|
+
}
|
|
4228
|
+
};
|
|
4229
|
+
|
|
4230
|
+
/**
|
|
4231
|
+
* Validates a scheduled signal before it is registered for activation.
|
|
4232
|
+
*
|
|
4233
|
+
* Checks:
|
|
4234
|
+
* - ISignalRow-specific fields: id, exchangeName, strategyName, symbol, _isScheduled
|
|
4235
|
+
* - currentPrice is a finite positive number
|
|
4236
|
+
* - Common signal fields via validateCommonSignal (position, prices, TP/SL relationships, minuteEstimatedTime)
|
|
4237
|
+
* - priceOpen is between SL and TP — position would not be immediately closed upon activation
|
|
4238
|
+
* - scheduledAt is a positive number (pendingAt === 0 is allowed until activation)
|
|
4239
|
+
*
|
|
4240
|
+
* @deprecated This is an internal code exported for unit tests only. Use `validateSignal` in Strategy::getSignal
|
|
4241
|
+
*
|
|
4242
|
+
* @param signal - Scheduled signal row to validate
|
|
4243
|
+
* @param currentPrice - Current market price at the moment of signal creation
|
|
4244
|
+
* @throws {Error} If any validation check fails
|
|
4245
|
+
*/
|
|
4246
|
+
const validateScheduledSignal = (signal, currentPrice) => {
|
|
4247
|
+
const errors = [];
|
|
4248
|
+
// ПРОВЕРКА ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ ISignalRow
|
|
4249
|
+
{
|
|
4250
|
+
if (signal.id === undefined || signal.id === null || signal.id === '') {
|
|
4251
|
+
errors.push('id is required and must be a non-empty string');
|
|
4252
|
+
}
|
|
4253
|
+
if (signal.exchangeName === undefined || signal.exchangeName === null || signal.exchangeName === '') {
|
|
4254
|
+
errors.push('exchangeName is required');
|
|
4255
|
+
}
|
|
4256
|
+
if (signal.strategyName === undefined || signal.strategyName === null || signal.strategyName === '') {
|
|
4257
|
+
errors.push('strategyName is required');
|
|
4258
|
+
}
|
|
4259
|
+
if (signal.symbol === undefined || signal.symbol === null || signal.symbol === '') {
|
|
4260
|
+
errors.push('symbol is required and must be a non-empty string');
|
|
4261
|
+
}
|
|
4262
|
+
if (signal._isScheduled === undefined || signal._isScheduled === null) {
|
|
4263
|
+
errors.push('_isScheduled is required');
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
// ЗАЩИТА ОТ NaN/Infinity: currentPrice должна быть конечным числом
|
|
4267
|
+
{
|
|
4268
|
+
if (typeof currentPrice !== "number") {
|
|
4269
|
+
errors.push(`currentPrice must be a number type, got ${currentPrice} (${typeof currentPrice})`);
|
|
4270
|
+
}
|
|
4271
|
+
if (!isFinite(currentPrice)) {
|
|
4272
|
+
errors.push(`currentPrice must be a finite number, got ${currentPrice} (${typeof currentPrice})`);
|
|
4273
|
+
}
|
|
4274
|
+
if (isFinite(currentPrice) && currentPrice <= 0) {
|
|
4275
|
+
errors.push(`currentPrice must be positive, got ${currentPrice}`);
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
if (errors.length > 0) {
|
|
4279
|
+
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
4280
|
+
}
|
|
4281
|
+
validateCommonSignal(signal);
|
|
4282
|
+
// ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
|
|
4283
|
+
if (signal.position === "long") {
|
|
4284
|
+
if (isFinite(signal.priceOpen)) {
|
|
4285
|
+
// LONG scheduled: priceOpen должен быть МЕЖДУ SL и TP
|
|
4286
|
+
// SL < priceOpen < TP
|
|
4287
|
+
if (signal.priceOpen <= signal.priceStopLoss) {
|
|
4288
|
+
errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4289
|
+
`Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
|
|
4290
|
+
}
|
|
4291
|
+
if (signal.priceOpen >= signal.priceTakeProfit) {
|
|
4292
|
+
errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4293
|
+
`Signal would close immediately on activation. This is logically impossible for LONG position.`);
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
if (signal.position === "short") {
|
|
4298
|
+
if (isFinite(signal.priceOpen)) {
|
|
4299
|
+
// SHORT scheduled: priceOpen должен быть МЕЖДУ TP и SL
|
|
4300
|
+
// TP < priceOpen < SL
|
|
4301
|
+
if (signal.priceOpen >= signal.priceStopLoss) {
|
|
4302
|
+
errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4303
|
+
`Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
|
|
4304
|
+
}
|
|
4305
|
+
if (signal.priceOpen <= signal.priceTakeProfit) {
|
|
4306
|
+
errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4307
|
+
`Signal would close immediately on activation. This is logically impossible for SHORT position.`);
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
// Валидация временных меток
|
|
4312
|
+
{
|
|
4313
|
+
if (typeof signal.scheduledAt !== "number") {
|
|
4314
|
+
errors.push(`scheduledAt must be a number type, got ${signal.scheduledAt} (${typeof signal.scheduledAt})`);
|
|
4315
|
+
}
|
|
4316
|
+
if (signal.scheduledAt <= 0) {
|
|
4317
|
+
errors.push(`scheduledAt must be positive, got ${signal.scheduledAt}`);
|
|
4318
|
+
}
|
|
4319
|
+
if (typeof signal.pendingAt !== "number") {
|
|
4320
|
+
errors.push(`pendingAt must be a number type, got ${signal.pendingAt} (${typeof signal.pendingAt})`);
|
|
4321
|
+
}
|
|
4322
|
+
// pendingAt === 0 is allowed for scheduled signals (set to SCHEDULED_SIGNAL_PENDING_MOCK until activation)
|
|
4323
|
+
}
|
|
4324
|
+
if (errors.length > 0) {
|
|
4325
|
+
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
4326
|
+
}
|
|
4327
|
+
};
|
|
4328
|
+
|
|
3931
4329
|
const INTERVAL_MINUTES$6 = {
|
|
3932
4330
|
"1m": 1,
|
|
3933
4331
|
"3m": 3,
|
|
@@ -4306,272 +4704,6 @@ const TO_PUBLIC_SIGNAL = (signal, currentPrice) => {
|
|
|
4306
4704
|
pnl: toProfitLossDto(signal, currentPrice),
|
|
4307
4705
|
};
|
|
4308
4706
|
};
|
|
4309
|
-
const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
|
|
4310
|
-
const errors = [];
|
|
4311
|
-
// ПРОВЕРКА ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ ISignalRow
|
|
4312
|
-
{
|
|
4313
|
-
if (signal.id === undefined || signal.id === null || signal.id === '') {
|
|
4314
|
-
errors.push('id is required and must be a non-empty string');
|
|
4315
|
-
}
|
|
4316
|
-
if (signal.exchangeName === undefined || signal.exchangeName === null || signal.exchangeName === '') {
|
|
4317
|
-
errors.push('exchangeName is required');
|
|
4318
|
-
}
|
|
4319
|
-
if (signal.strategyName === undefined || signal.strategyName === null || signal.strategyName === '') {
|
|
4320
|
-
errors.push('strategyName is required');
|
|
4321
|
-
}
|
|
4322
|
-
if (signal.symbol === undefined || signal.symbol === null || signal.symbol === '') {
|
|
4323
|
-
errors.push('symbol is required and must be a non-empty string');
|
|
4324
|
-
}
|
|
4325
|
-
if (signal._isScheduled === undefined || signal._isScheduled === null) {
|
|
4326
|
-
errors.push('_isScheduled is required');
|
|
4327
|
-
}
|
|
4328
|
-
if (signal.position === undefined || signal.position === null) {
|
|
4329
|
-
errors.push('position is required and must be "long" or "short"');
|
|
4330
|
-
}
|
|
4331
|
-
if (signal.position !== "long" && signal.position !== "short") {
|
|
4332
|
-
errors.push(`position must be "long" or "short", got "${signal.position}"`);
|
|
4333
|
-
}
|
|
4334
|
-
}
|
|
4335
|
-
// ЗАЩИТА ОТ NaN/Infinity: currentPrice должна быть конечным числом
|
|
4336
|
-
{
|
|
4337
|
-
if (typeof currentPrice !== "number") {
|
|
4338
|
-
errors.push(`currentPrice must be a number type, got ${currentPrice} (${typeof currentPrice})`);
|
|
4339
|
-
}
|
|
4340
|
-
if (!isFinite(currentPrice)) {
|
|
4341
|
-
errors.push(`currentPrice must be a finite number, got ${currentPrice} (${typeof currentPrice})`);
|
|
4342
|
-
}
|
|
4343
|
-
if (isFinite(currentPrice) && currentPrice <= 0) {
|
|
4344
|
-
errors.push(`currentPrice must be positive, got ${currentPrice}`);
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
// ЗАЩИТА ОТ NaN/Infinity: все цены должны быть конечными числами
|
|
4348
|
-
{
|
|
4349
|
-
if (typeof signal.priceOpen !== "number") {
|
|
4350
|
-
errors.push(`priceOpen must be a number type, got ${signal.priceOpen} (${typeof signal.priceOpen})`);
|
|
4351
|
-
}
|
|
4352
|
-
if (!isFinite(signal.priceOpen)) {
|
|
4353
|
-
errors.push(`priceOpen must be a finite number, got ${signal.priceOpen} (${typeof signal.priceOpen})`);
|
|
4354
|
-
}
|
|
4355
|
-
if (typeof signal.priceTakeProfit !== "number") {
|
|
4356
|
-
errors.push(`priceTakeProfit must be a number type, got ${signal.priceTakeProfit} (${typeof signal.priceTakeProfit})`);
|
|
4357
|
-
}
|
|
4358
|
-
if (!isFinite(signal.priceTakeProfit)) {
|
|
4359
|
-
errors.push(`priceTakeProfit must be a finite number, got ${signal.priceTakeProfit} (${typeof signal.priceTakeProfit})`);
|
|
4360
|
-
}
|
|
4361
|
-
if (typeof signal.priceStopLoss !== "number") {
|
|
4362
|
-
errors.push(`priceStopLoss must be a number type, got ${signal.priceStopLoss} (${typeof signal.priceStopLoss})`);
|
|
4363
|
-
}
|
|
4364
|
-
if (!isFinite(signal.priceStopLoss)) {
|
|
4365
|
-
errors.push(`priceStopLoss must be a finite number, got ${signal.priceStopLoss} (${typeof signal.priceStopLoss})`);
|
|
4366
|
-
}
|
|
4367
|
-
}
|
|
4368
|
-
// Валидация цен (только если они конечные)
|
|
4369
|
-
{
|
|
4370
|
-
if (isFinite(signal.priceOpen) && signal.priceOpen <= 0) {
|
|
4371
|
-
errors.push(`priceOpen must be positive, got ${signal.priceOpen}`);
|
|
4372
|
-
}
|
|
4373
|
-
if (isFinite(signal.priceTakeProfit) && signal.priceTakeProfit <= 0) {
|
|
4374
|
-
errors.push(`priceTakeProfit must be positive, got ${signal.priceTakeProfit}`);
|
|
4375
|
-
}
|
|
4376
|
-
if (isFinite(signal.priceStopLoss) && signal.priceStopLoss <= 0) {
|
|
4377
|
-
errors.push(`priceStopLoss must be positive, got ${signal.priceStopLoss}`);
|
|
4378
|
-
}
|
|
4379
|
-
}
|
|
4380
|
-
// Валидация для long позиции
|
|
4381
|
-
if (signal.position === "long") {
|
|
4382
|
-
// Проверка соотношения цен для long
|
|
4383
|
-
{
|
|
4384
|
-
if (signal.priceTakeProfit <= signal.priceOpen) {
|
|
4385
|
-
errors.push(`Long: priceTakeProfit (${signal.priceTakeProfit}) must be > priceOpen (${signal.priceOpen})`);
|
|
4386
|
-
}
|
|
4387
|
-
if (signal.priceStopLoss >= signal.priceOpen) {
|
|
4388
|
-
errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
|
|
4389
|
-
}
|
|
4390
|
-
}
|
|
4391
|
-
// ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
|
|
4392
|
-
{
|
|
4393
|
-
if (!isScheduled && isFinite(currentPrice)) {
|
|
4394
|
-
// LONG: currentPrice должна быть МЕЖДУ SL и TP (не пробита ни одна граница)
|
|
4395
|
-
// SL < currentPrice < TP
|
|
4396
|
-
if (currentPrice <= signal.priceStopLoss) {
|
|
4397
|
-
errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4398
|
-
`Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
|
|
4399
|
-
}
|
|
4400
|
-
if (currentPrice >= signal.priceTakeProfit) {
|
|
4401
|
-
errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4402
|
-
`Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
|
|
4403
|
-
}
|
|
4404
|
-
}
|
|
4405
|
-
}
|
|
4406
|
-
// ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
|
|
4407
|
-
{
|
|
4408
|
-
if (isScheduled && isFinite(signal.priceOpen)) {
|
|
4409
|
-
// LONG scheduled: priceOpen должен быть МЕЖДУ SL и TP
|
|
4410
|
-
// SL < priceOpen < TP
|
|
4411
|
-
if (signal.priceOpen <= signal.priceStopLoss) {
|
|
4412
|
-
errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4413
|
-
`Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
|
|
4414
|
-
}
|
|
4415
|
-
if (signal.priceOpen >= signal.priceTakeProfit) {
|
|
4416
|
-
errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4417
|
-
`Signal would close immediately on activation. This is logically impossible for LONG position.`);
|
|
4418
|
-
}
|
|
4419
|
-
}
|
|
4420
|
-
}
|
|
4421
|
-
// ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
|
|
4422
|
-
{
|
|
4423
|
-
if (GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4424
|
-
const tpDistancePercent = ((signal.priceTakeProfit - signal.priceOpen) / signal.priceOpen) * 100;
|
|
4425
|
-
if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4426
|
-
errors.push(`Long: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
|
|
4427
|
-
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
|
|
4428
|
-
`Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
|
|
4429
|
-
}
|
|
4430
|
-
}
|
|
4431
|
-
}
|
|
4432
|
-
// ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
|
|
4433
|
-
{
|
|
4434
|
-
if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4435
|
-
const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
|
|
4436
|
-
if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4437
|
-
errors.push(`Long: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4438
|
-
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
|
|
4439
|
-
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4440
|
-
}
|
|
4441
|
-
}
|
|
4442
|
-
}
|
|
4443
|
-
// ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
|
|
4444
|
-
{
|
|
4445
|
-
if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4446
|
-
const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
|
|
4447
|
-
if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4448
|
-
errors.push(`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4449
|
-
`Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
|
|
4450
|
-
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4451
|
-
}
|
|
4452
|
-
}
|
|
4453
|
-
}
|
|
4454
|
-
}
|
|
4455
|
-
// Валидация для short позиции
|
|
4456
|
-
if (signal.position === "short") {
|
|
4457
|
-
// Проверка соотношения цен для short
|
|
4458
|
-
{
|
|
4459
|
-
if (signal.priceTakeProfit >= signal.priceOpen) {
|
|
4460
|
-
errors.push(`Short: priceTakeProfit (${signal.priceTakeProfit}) must be < priceOpen (${signal.priceOpen})`);
|
|
4461
|
-
}
|
|
4462
|
-
if (signal.priceStopLoss <= signal.priceOpen) {
|
|
4463
|
-
errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
|
|
4464
|
-
}
|
|
4465
|
-
}
|
|
4466
|
-
// ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
|
|
4467
|
-
{
|
|
4468
|
-
if (!isScheduled && isFinite(currentPrice)) {
|
|
4469
|
-
// SHORT: currentPrice должна быть МЕЖДУ TP и SL (не пробита ни одна граница)
|
|
4470
|
-
// TP < currentPrice < SL
|
|
4471
|
-
if (currentPrice >= signal.priceStopLoss) {
|
|
4472
|
-
errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4473
|
-
`Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
|
|
4474
|
-
}
|
|
4475
|
-
if (currentPrice <= signal.priceTakeProfit) {
|
|
4476
|
-
errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4477
|
-
`Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
|
|
4478
|
-
}
|
|
4479
|
-
}
|
|
4480
|
-
}
|
|
4481
|
-
// ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
|
|
4482
|
-
{
|
|
4483
|
-
if (isScheduled && isFinite(signal.priceOpen)) {
|
|
4484
|
-
// SHORT scheduled: priceOpen должен быть МЕЖДУ TP и SL
|
|
4485
|
-
// TP < priceOpen < SL
|
|
4486
|
-
if (signal.priceOpen >= signal.priceStopLoss) {
|
|
4487
|
-
errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
4488
|
-
`Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
|
|
4489
|
-
}
|
|
4490
|
-
if (signal.priceOpen <= signal.priceTakeProfit) {
|
|
4491
|
-
errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
4492
|
-
`Signal would close immediately on activation. This is logically impossible for SHORT position.`);
|
|
4493
|
-
}
|
|
4494
|
-
}
|
|
4495
|
-
}
|
|
4496
|
-
// ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
|
|
4497
|
-
{
|
|
4498
|
-
if (GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4499
|
-
const tpDistancePercent = ((signal.priceOpen - signal.priceTakeProfit) / signal.priceOpen) * 100;
|
|
4500
|
-
if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
|
|
4501
|
-
errors.push(`Short: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
|
|
4502
|
-
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
|
|
4503
|
-
`Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
|
|
4504
|
-
}
|
|
4505
|
-
}
|
|
4506
|
-
}
|
|
4507
|
-
// ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
|
|
4508
|
-
{
|
|
4509
|
-
if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4510
|
-
const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
|
|
4511
|
-
if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
|
|
4512
|
-
errors.push(`Short: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4513
|
-
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
|
|
4514
|
-
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4515
|
-
}
|
|
4516
|
-
}
|
|
4517
|
-
}
|
|
4518
|
-
// ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
|
|
4519
|
-
{
|
|
4520
|
-
if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4521
|
-
const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
|
|
4522
|
-
if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
|
|
4523
|
-
errors.push(`Short: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
|
|
4524
|
-
`Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
|
|
4525
|
-
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
|
|
4526
|
-
}
|
|
4527
|
-
}
|
|
4528
|
-
}
|
|
4529
|
-
}
|
|
4530
|
-
// Валидация временных параметров
|
|
4531
|
-
{
|
|
4532
|
-
if (typeof signal.minuteEstimatedTime !== "number") {
|
|
4533
|
-
errors.push(`minuteEstimatedTime must be a number type, got ${signal.minuteEstimatedTime} (${typeof signal.minuteEstimatedTime})`);
|
|
4534
|
-
}
|
|
4535
|
-
if (signal.minuteEstimatedTime <= 0) {
|
|
4536
|
-
errors.push(`minuteEstimatedTime must be positive, got ${signal.minuteEstimatedTime}`);
|
|
4537
|
-
}
|
|
4538
|
-
if (signal.minuteEstimatedTime === Infinity && GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES !== Infinity) {
|
|
4539
|
-
errors.push(`minuteEstimatedTime cannot be Infinity when CC_MAX_SIGNAL_LIFETIME_MINUTES is not Infinity`);
|
|
4540
|
-
}
|
|
4541
|
-
if (signal.minuteEstimatedTime !== Infinity && !Number.isInteger(signal.minuteEstimatedTime)) {
|
|
4542
|
-
errors.push(`minuteEstimatedTime must be an integer (whole number), got ${signal.minuteEstimatedTime}`);
|
|
4543
|
-
}
|
|
4544
|
-
}
|
|
4545
|
-
// ЗАЩИТА ОТ ВЕЧНЫХ СИГНАЛОВ: ограничиваем максимальное время жизни сигнала
|
|
4546
|
-
{
|
|
4547
|
-
if (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES !== Infinity && signal.minuteEstimatedTime > GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES) {
|
|
4548
|
-
const days = (signal.minuteEstimatedTime / 60 / 24).toFixed(1);
|
|
4549
|
-
const maxDays = (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES / 60 / 24).toFixed(0);
|
|
4550
|
-
errors.push(`minuteEstimatedTime too large (${signal.minuteEstimatedTime} minutes = ${days} days). ` +
|
|
4551
|
-
`Maximum: ${GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES} minutes (${maxDays} days) to prevent strategy deadlock. ` +
|
|
4552
|
-
`Eternal signals block risk limits and prevent new trades.`);
|
|
4553
|
-
}
|
|
4554
|
-
}
|
|
4555
|
-
// Валидация временных меток
|
|
4556
|
-
{
|
|
4557
|
-
if (typeof signal.scheduledAt !== "number") {
|
|
4558
|
-
errors.push(`scheduledAt must be a number type, got ${signal.scheduledAt} (${typeof signal.scheduledAt})`);
|
|
4559
|
-
}
|
|
4560
|
-
if (signal.scheduledAt <= 0) {
|
|
4561
|
-
errors.push(`scheduledAt must be positive, got ${signal.scheduledAt}`);
|
|
4562
|
-
}
|
|
4563
|
-
if (typeof signal.pendingAt !== "number") {
|
|
4564
|
-
errors.push(`pendingAt must be a number type, got ${signal.pendingAt} (${typeof signal.pendingAt})`);
|
|
4565
|
-
}
|
|
4566
|
-
if (signal.pendingAt <= 0 && !isScheduled) {
|
|
4567
|
-
errors.push(`pendingAt must be positive, got ${signal.pendingAt}`);
|
|
4568
|
-
}
|
|
4569
|
-
}
|
|
4570
|
-
// Кидаем ошибку если есть проблемы
|
|
4571
|
-
if (errors.length > 0) {
|
|
4572
|
-
throw new Error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
4573
|
-
}
|
|
4574
|
-
};
|
|
4575
4707
|
const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
4576
4708
|
if (self._isStopped) {
|
|
4577
4709
|
return null;
|
|
@@ -4637,7 +4769,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
4637
4769
|
_peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0 },
|
|
4638
4770
|
};
|
|
4639
4771
|
// Валидируем сигнал перед возвратом
|
|
4640
|
-
|
|
4772
|
+
validatePendingSignal(signalRow, currentPrice);
|
|
4641
4773
|
return signalRow;
|
|
4642
4774
|
}
|
|
4643
4775
|
// ОЖИДАНИЕ АКТИВАЦИИ: создаем scheduled signal (risk check при активации)
|
|
@@ -4662,7 +4794,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
4662
4794
|
_peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0 },
|
|
4663
4795
|
};
|
|
4664
4796
|
// Валидируем сигнал перед возвратом
|
|
4665
|
-
|
|
4797
|
+
validateScheduledSignal(scheduledSignalRow, currentPrice);
|
|
4666
4798
|
return scheduledSignalRow;
|
|
4667
4799
|
}
|
|
4668
4800
|
const signalRow = {
|
|
@@ -4684,7 +4816,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
4684
4816
|
_peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0 },
|
|
4685
4817
|
};
|
|
4686
4818
|
// Валидируем сигнал перед возвратом
|
|
4687
|
-
|
|
4819
|
+
validatePendingSignal(signalRow, currentPrice);
|
|
4688
4820
|
return signalRow;
|
|
4689
4821
|
}, {
|
|
4690
4822
|
defaultValue: null,
|
|
@@ -4874,8 +5006,12 @@ const TRAILING_STOP_LOSS_FN = (self, signal, percentShift) => {
|
|
|
4874
5006
|
// CRITICAL: Larger percentShift absorbs smaller one
|
|
4875
5007
|
// For LONG: higher SL (closer to entry) absorbs lower one
|
|
4876
5008
|
// For SHORT: lower SL (closer to entry) absorbs higher one
|
|
5009
|
+
// When CC_ENABLE_TRAILING_EVERYWHERE is true, absorption check is skipped
|
|
4877
5010
|
let shouldUpdate = false;
|
|
4878
|
-
if (
|
|
5011
|
+
if (GLOBAL_CONFIG.CC_ENABLE_TRAILING_EVERYWHERE) {
|
|
5012
|
+
shouldUpdate = true;
|
|
5013
|
+
}
|
|
5014
|
+
else if (signal.position === "long") {
|
|
4879
5015
|
// LONG: update only if new SL is higher (better protection)
|
|
4880
5016
|
shouldUpdate = newStopLoss > currentTrailingSL;
|
|
4881
5017
|
}
|
|
@@ -4956,8 +5092,12 @@ const TRAILING_TAKE_PROFIT_FN = (self, signal, percentShift) => {
|
|
|
4956
5092
|
// CRITICAL: Larger percentShift absorbs smaller one
|
|
4957
5093
|
// For LONG: lower TP (closer to entry) absorbs higher one
|
|
4958
5094
|
// For SHORT: higher TP (closer to entry) absorbs lower one
|
|
5095
|
+
// When CC_ENABLE_TRAILING_EVERYWHERE is true, absorption check is skipped
|
|
4959
5096
|
let shouldUpdate = false;
|
|
4960
|
-
if (
|
|
5097
|
+
if (GLOBAL_CONFIG.CC_ENABLE_TRAILING_EVERYWHERE) {
|
|
5098
|
+
shouldUpdate = true;
|
|
5099
|
+
}
|
|
5100
|
+
else if (signal.position === "long") {
|
|
4961
5101
|
// LONG: update only if new TP is lower (closer to entry, more conservative)
|
|
4962
5102
|
shouldUpdate = newTakeProfit < currentTrailingTP;
|
|
4963
5103
|
}
|
|
@@ -6345,7 +6485,7 @@ const CLOSE_USER_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, closedSignal, aver
|
|
|
6345
6485
|
await CALL_TICK_CALLBACKS_FN(self, self.params.execution.context.symbol, result, closeTimestamp, self.params.execution.context.backtest);
|
|
6346
6486
|
return result;
|
|
6347
6487
|
};
|
|
6348
|
-
const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
|
|
6488
|
+
const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, frameEndTime) => {
|
|
6349
6489
|
const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
|
|
6350
6490
|
const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
|
|
6351
6491
|
const bufferCandlesCount = candlesCount - 1;
|
|
@@ -6358,6 +6498,11 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
|
|
|
6358
6498
|
}
|
|
6359
6499
|
const recentCandles = candles.slice(Math.max(0, i - (candlesCount - 1)), i + 1);
|
|
6360
6500
|
const averagePrice = GET_AVG_PRICE_FN(recentCandles);
|
|
6501
|
+
// Если timestamp свечи вышел за frameEndTime — отменяем scheduled сигнал
|
|
6502
|
+
if (candle.timestamp > frameEndTime) {
|
|
6503
|
+
const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "timeout");
|
|
6504
|
+
return { outcome: "cancelled", result };
|
|
6505
|
+
}
|
|
6361
6506
|
// КРИТИЧНО: Проверяем был ли сигнал отменен пользователем через cancel()
|
|
6362
6507
|
if (self._cancelledSignal) {
|
|
6363
6508
|
// Сигнал был отменен через cancel() в onSchedulePing
|
|
@@ -6505,7 +6650,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
|
|
|
6505
6650
|
}
|
|
6506
6651
|
return { outcome: "pending" };
|
|
6507
6652
|
};
|
|
6508
|
-
const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
|
|
6653
|
+
const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles, frameEndTime) => {
|
|
6509
6654
|
const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
|
|
6510
6655
|
const bufferCandlesCount = candlesCount - 1;
|
|
6511
6656
|
// КРИТИЧНО: проверяем TP/SL на КАЖДОЙ свече начиная после буфера
|
|
@@ -6522,6 +6667,14 @@ const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
|
|
|
6522
6667
|
const startIndex = Math.max(0, i - (candlesCount - 1));
|
|
6523
6668
|
const recentCandles = candles.slice(startIndex, i + 1);
|
|
6524
6669
|
const averagePrice = GET_AVG_PRICE_FN(recentCandles);
|
|
6670
|
+
// Если timestamp свечи вышел за frameEndTime — закрываем pending сигнал по time_expired
|
|
6671
|
+
if (currentCandleTimestamp > frameEndTime) {
|
|
6672
|
+
const result = await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(self, signal, averagePrice, "time_expired", currentCandleTimestamp);
|
|
6673
|
+
if (!result) {
|
|
6674
|
+
throw new Error(`ClientStrategy backtest: frameEndTime time_expired close rejected by sync (signalId=${signal.id}).`);
|
|
6675
|
+
}
|
|
6676
|
+
return result;
|
|
6677
|
+
}
|
|
6525
6678
|
// КРИТИЧНО: Проверяем был ли сигнал закрыт пользователем через closePending()
|
|
6526
6679
|
if (self._closedSignal) {
|
|
6527
6680
|
return await CLOSE_USER_PENDING_SIGNAL_IN_BACKTEST_FN(self, self._closedSignal, averagePrice, currentCandleTimestamp);
|
|
@@ -7687,7 +7840,7 @@ class ClientStrategy {
|
|
|
7687
7840
|
* console.log(result.closeReason); // "take_profit" | "stop_loss" | "time_expired" | "cancelled"
|
|
7688
7841
|
* ```
|
|
7689
7842
|
*/
|
|
7690
|
-
async backtest(symbol, strategyName, candles) {
|
|
7843
|
+
async backtest(symbol, strategyName, candles, frameEndTime) {
|
|
7691
7844
|
this.params.logger.debug("ClientStrategy backtest", {
|
|
7692
7845
|
symbol,
|
|
7693
7846
|
strategyName,
|
|
@@ -7695,6 +7848,7 @@ class ClientStrategy {
|
|
|
7695
7848
|
candlesCount: candles.length,
|
|
7696
7849
|
hasScheduled: !!this._scheduledSignal,
|
|
7697
7850
|
hasPending: !!this._pendingSignal,
|
|
7851
|
+
frameEndTime,
|
|
7698
7852
|
});
|
|
7699
7853
|
if (!this.params.execution.context.backtest) {
|
|
7700
7854
|
throw new Error("ClientStrategy backtest: running in live context");
|
|
@@ -7813,7 +7967,7 @@ class ClientStrategy {
|
|
|
7813
7967
|
priceOpen: scheduled.priceOpen,
|
|
7814
7968
|
position: scheduled.position,
|
|
7815
7969
|
});
|
|
7816
|
-
const scheduledResult = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles);
|
|
7970
|
+
const scheduledResult = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles, frameEndTime);
|
|
7817
7971
|
if (scheduledResult.outcome === "cancelled") {
|
|
7818
7972
|
return scheduledResult.result;
|
|
7819
7973
|
}
|
|
@@ -7890,7 +8044,7 @@ class ClientStrategy {
|
|
|
7890
8044
|
if (candles.length < candlesCount) {
|
|
7891
8045
|
this.params.logger.warn(`ClientStrategy backtest: Expected at least ${candlesCount} candles for VWAP, got ${candles.length}`);
|
|
7892
8046
|
}
|
|
7893
|
-
return await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles);
|
|
8047
|
+
return await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles, frameEndTime);
|
|
7894
8048
|
}
|
|
7895
8049
|
/**
|
|
7896
8050
|
* Stops the strategy from generating new signals.
|
|
@@ -8685,12 +8839,15 @@ class ClientStrategy {
|
|
|
8685
8839
|
if (signal.position === "short" && newStopLoss <= effectiveTakeProfit)
|
|
8686
8840
|
return false;
|
|
8687
8841
|
// Absorption check (mirrors TRAILING_STOP_LOSS_FN: first call is unconditional)
|
|
8688
|
-
|
|
8689
|
-
if (
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8842
|
+
// When CC_ENABLE_TRAILING_EVERYWHERE is true, absorption check is skipped
|
|
8843
|
+
if (!GLOBAL_CONFIG.CC_ENABLE_TRAILING_EVERYWHERE) {
|
|
8844
|
+
const currentTrailingSL = signal._trailingPriceStopLoss;
|
|
8845
|
+
if (currentTrailingSL !== undefined) {
|
|
8846
|
+
if (signal.position === "long" && newStopLoss <= currentTrailingSL)
|
|
8847
|
+
return false;
|
|
8848
|
+
if (signal.position === "short" && newStopLoss >= currentTrailingSL)
|
|
8849
|
+
return false;
|
|
8850
|
+
}
|
|
8694
8851
|
}
|
|
8695
8852
|
return true;
|
|
8696
8853
|
}
|
|
@@ -8932,12 +9089,15 @@ class ClientStrategy {
|
|
|
8932
9089
|
if (signal.position === "short" && newTakeProfit >= effectiveStopLoss)
|
|
8933
9090
|
return false;
|
|
8934
9091
|
// Absorption check (mirrors TRAILING_TAKE_PROFIT_FN: first call is unconditional)
|
|
8935
|
-
|
|
8936
|
-
if (
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
9092
|
+
// When CC_ENABLE_TRAILING_EVERYWHERE is true, absorption check is skipped
|
|
9093
|
+
if (!GLOBAL_CONFIG.CC_ENABLE_TRAILING_EVERYWHERE) {
|
|
9094
|
+
const currentTrailingTP = signal._trailingPriceTakeProfit;
|
|
9095
|
+
if (currentTrailingTP !== undefined) {
|
|
9096
|
+
if (signal.position === "long" && newTakeProfit >= currentTrailingTP)
|
|
9097
|
+
return false;
|
|
9098
|
+
if (signal.position === "short" && newTakeProfit <= currentTrailingTP)
|
|
9099
|
+
return false;
|
|
9100
|
+
}
|
|
8941
9101
|
}
|
|
8942
9102
|
return true;
|
|
8943
9103
|
}
|
|
@@ -10283,17 +10443,18 @@ class StrategyConnectionService {
|
|
|
10283
10443
|
* @param candles - Array of historical candle data to backtest
|
|
10284
10444
|
* @returns Promise resolving to backtest result (signal or idle)
|
|
10285
10445
|
*/
|
|
10286
|
-
this.backtest = async (symbol, context, candles) => {
|
|
10446
|
+
this.backtest = async (symbol, context, candles, frameEndTime) => {
|
|
10287
10447
|
const backtest = this.executionContextService.context.backtest;
|
|
10288
10448
|
this.loggerService.log("strategyConnectionService backtest", {
|
|
10289
10449
|
symbol,
|
|
10290
10450
|
context,
|
|
10291
10451
|
candleCount: candles.length,
|
|
10452
|
+
frameEndTime,
|
|
10292
10453
|
backtest,
|
|
10293
10454
|
});
|
|
10294
10455
|
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
10295
10456
|
await strategy.waitForInit();
|
|
10296
|
-
const tick = await strategy.backtest(symbol, context.strategyName, candles);
|
|
10457
|
+
const tick = await strategy.backtest(symbol, context.strategyName, candles, frameEndTime);
|
|
10297
10458
|
{
|
|
10298
10459
|
await CALL_SIGNAL_EMIT_FN(this, tick, context, backtest, symbol);
|
|
10299
10460
|
}
|
|
@@ -10960,7 +11121,8 @@ const INTERVAL_MINUTES$5 = {
|
|
|
10960
11121
|
"8h": 480,
|
|
10961
11122
|
"12h": 720,
|
|
10962
11123
|
"1d": 1440,
|
|
10963
|
-
"
|
|
11124
|
+
"1w": 10080,
|
|
11125
|
+
"1M": 43200,
|
|
10964
11126
|
};
|
|
10965
11127
|
/**
|
|
10966
11128
|
* Wrapper to call onTimeframe callback with error handling.
|
|
@@ -11379,6 +11541,8 @@ const INTERVAL_MINUTES$4 = {
|
|
|
11379
11541
|
"4h": 240,
|
|
11380
11542
|
"6h": 360,
|
|
11381
11543
|
"8h": 480,
|
|
11544
|
+
"1d": 1440,
|
|
11545
|
+
"1w": 10080,
|
|
11382
11546
|
};
|
|
11383
11547
|
/**
|
|
11384
11548
|
* Aligns timestamp down to the nearest interval boundary.
|
|
@@ -14228,17 +14392,18 @@ class StrategyCoreService {
|
|
|
14228
14392
|
* @param context - Execution context with strategyName, exchangeName, frameName
|
|
14229
14393
|
* @returns Closed signal result with PNL
|
|
14230
14394
|
*/
|
|
14231
|
-
this.backtest = async (symbol, candles, when, backtest, context) => {
|
|
14395
|
+
this.backtest = async (symbol, candles, frameEndTime, when, backtest, context) => {
|
|
14232
14396
|
this.loggerService.log("strategyCoreService backtest", {
|
|
14233
14397
|
symbol,
|
|
14234
14398
|
candleCount: candles.length,
|
|
14235
14399
|
when,
|
|
14236
14400
|
backtest,
|
|
14237
14401
|
context,
|
|
14402
|
+
frameEndTime,
|
|
14238
14403
|
});
|
|
14239
14404
|
await this.validate(context);
|
|
14240
14405
|
return await ExecutionContextService.runInContext(async () => {
|
|
14241
|
-
return await this.strategyConnectionService.backtest(symbol, context, candles);
|
|
14406
|
+
return await this.strategyConnectionService.backtest(symbol, context, candles, frameEndTime);
|
|
14242
14407
|
}, {
|
|
14243
14408
|
symbol,
|
|
14244
14409
|
when,
|
|
@@ -16231,7 +16396,9 @@ const TICK_FN = async (self, symbol, when) => {
|
|
|
16231
16396
|
});
|
|
16232
16397
|
}
|
|
16233
16398
|
catch (error) {
|
|
16234
|
-
console.error(`backtestLogicPrivateService tick failed symbol=${symbol} when=${when.toISOString()} strategyName=${self.methodContextService.context.strategyName} exchangeName=${self.methodContextService.context.exchangeName}
|
|
16399
|
+
console.error(`backtestLogicPrivateService tick failed symbol=${symbol} when=${when.toISOString()} strategyName=${self.methodContextService.context.strategyName} exchangeName=${self.methodContextService.context.exchangeName} error=${functoolsKit.getErrorMessage(error)}`, {
|
|
16400
|
+
error: functoolsKit.errorData(error),
|
|
16401
|
+
});
|
|
16235
16402
|
self.loggerService.warn("backtestLogicPrivateService tick failed", {
|
|
16236
16403
|
symbol,
|
|
16237
16404
|
when: when.toISOString(),
|
|
@@ -16257,9 +16424,9 @@ const GET_CANDLES_FN = async (self, symbol, candlesNeeded, bufferStartTime, logM
|
|
|
16257
16424
|
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "GET_CANDLES_FN", message: functoolsKit.getErrorMessage(error) };
|
|
16258
16425
|
}
|
|
16259
16426
|
};
|
|
16260
|
-
const BACKTEST_FN = async (self, symbol, candles, when, context, logMeta) => {
|
|
16427
|
+
const BACKTEST_FN = async (self, symbol, candles, frameEndTime, when, context, logMeta) => {
|
|
16261
16428
|
try {
|
|
16262
|
-
return await self.strategyCoreService.backtest(symbol, candles, when, true, context);
|
|
16429
|
+
return await self.strategyCoreService.backtest(symbol, candles, frameEndTime, when, true, context);
|
|
16263
16430
|
}
|
|
16264
16431
|
catch (error) {
|
|
16265
16432
|
console.error(`backtestLogicPrivateService backtest failed symbol=${symbol} when=${when.toISOString()} strategyName=${context.strategyName} exchangeName=${context.exchangeName}`);
|
|
@@ -16272,7 +16439,29 @@ const BACKTEST_FN = async (self, symbol, candles, when, context, logMeta) => {
|
|
|
16272
16439
|
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "BACKTEST_FN", message: functoolsKit.getErrorMessage(error) };
|
|
16273
16440
|
}
|
|
16274
16441
|
};
|
|
16275
|
-
const
|
|
16442
|
+
const CLOSE_PENDING_FN = async (self, symbol, context, lastChunkCandles, frameEndTime, when, signalId) => {
|
|
16443
|
+
try {
|
|
16444
|
+
await self.strategyCoreService.closePending(true, symbol, context);
|
|
16445
|
+
}
|
|
16446
|
+
catch (error) {
|
|
16447
|
+
const message = `closePending failed: ${functoolsKit.getErrorMessage(error)}`;
|
|
16448
|
+
console.error(`backtestLogicPrivateService CLOSE_PENDING_FN: ${message} symbol=${symbol}`);
|
|
16449
|
+
await errorEmitter.next(error instanceof Error ? error : new Error(message));
|
|
16450
|
+
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "CLOSE_PENDING_FN", message };
|
|
16451
|
+
}
|
|
16452
|
+
const result = await BACKTEST_FN(self, symbol, lastChunkCandles, frameEndTime, when, context, { signalId });
|
|
16453
|
+
if ("__error__" in result) {
|
|
16454
|
+
return result;
|
|
16455
|
+
}
|
|
16456
|
+
if (result.action === "active") {
|
|
16457
|
+
const message = `signal ${signalId} still active after closePending`;
|
|
16458
|
+
console.error(`backtestLogicPrivateService CLOSE_PENDING_FN: ${message} symbol=${symbol}`);
|
|
16459
|
+
await errorEmitter.next(new Error(message));
|
|
16460
|
+
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "CLOSE_PENDING_FN", message };
|
|
16461
|
+
}
|
|
16462
|
+
return result;
|
|
16463
|
+
};
|
|
16464
|
+
const RUN_INFINITY_CHUNK_LOOP_FN = async (self, symbol, when, context, initialResult, bufferMs, signalId, frameEndTime) => {
|
|
16276
16465
|
let backtestResult = initialResult;
|
|
16277
16466
|
const CHUNK = GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST;
|
|
16278
16467
|
let lastChunkCandles = [];
|
|
@@ -16283,25 +16472,14 @@ const RUN_INFINITY_CHUNK_LOOP_FN = async (self, symbol, when, context, initialRe
|
|
|
16283
16472
|
return chunkCandles;
|
|
16284
16473
|
}
|
|
16285
16474
|
if (!chunkCandles.length) {
|
|
16286
|
-
await self
|
|
16287
|
-
const result = await BACKTEST_FN(self, symbol, lastChunkCandles, when, context, { signalId });
|
|
16288
|
-
if ("__error__" in result) {
|
|
16289
|
-
return result;
|
|
16290
|
-
}
|
|
16291
|
-
if (result.action === "active") {
|
|
16292
|
-
const message = `signal ${signalId} still active after closePending`;
|
|
16293
|
-
console.error(`backtestLogicPrivateService RUN_INFINITY_CHUNK_LOOP_FN: ${message} symbol=${symbol}`);
|
|
16294
|
-
await errorEmitter.next(new Error(message));
|
|
16295
|
-
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "RUN_INFINITY_CHUNK_LOOP_FN", message };
|
|
16296
|
-
}
|
|
16297
|
-
return result;
|
|
16475
|
+
return await CLOSE_PENDING_FN(self, symbol, context, lastChunkCandles, frameEndTime, when, signalId);
|
|
16298
16476
|
}
|
|
16299
16477
|
self.loggerService.info("backtestLogicPrivateService candles fetched for infinity chunk", {
|
|
16300
16478
|
symbol,
|
|
16301
16479
|
signalId,
|
|
16302
16480
|
candlesCount: chunkCandles.length,
|
|
16303
16481
|
});
|
|
16304
|
-
const chunkResult = await BACKTEST_FN(self, symbol, chunkCandles, when, context, { signalId });
|
|
16482
|
+
const chunkResult = await BACKTEST_FN(self, symbol, chunkCandles, frameEndTime, when, context, { signalId });
|
|
16305
16483
|
if ("__error__" in chunkResult) {
|
|
16306
16484
|
return chunkResult;
|
|
16307
16485
|
}
|
|
@@ -16346,7 +16524,7 @@ const EMIT_TIMEFRAME_PERFORMANCE_FN = async (self, symbol, timeframeStartTime, p
|
|
|
16346
16524
|
});
|
|
16347
16525
|
return currentTimestamp;
|
|
16348
16526
|
};
|
|
16349
|
-
const PROCESS_SCHEDULED_SIGNAL_FN = async function* (self, symbol, when, result, previousEventTimestamp) {
|
|
16527
|
+
const PROCESS_SCHEDULED_SIGNAL_FN = async function* (self, symbol, when, result, previousEventTimestamp, frameEndTime) {
|
|
16350
16528
|
const signalStartTime = performance.now();
|
|
16351
16529
|
const signal = result.signal;
|
|
16352
16530
|
self.loggerService.info("backtestLogicPrivateService scheduled signal detected", {
|
|
@@ -16366,6 +16544,10 @@ const PROCESS_SCHEDULED_SIGNAL_FN = async function* (self, symbol, when, result,
|
|
|
16366
16544
|
console.error(`backtestLogicPrivateService scheduled signal: getCandles failed, stopping backtest symbol=${symbol} signalId=${signal.id} reason=${candles.reason} message=${candles.message}`);
|
|
16367
16545
|
return candles;
|
|
16368
16546
|
}
|
|
16547
|
+
// No candles available for this scheduled signal — the frame ends before the signal
|
|
16548
|
+
// could be evaluated. Unlike pending (Infinity) signals that require CLOSE_PENDING_FN,
|
|
16549
|
+
// a scheduled signal that never activated needs no explicit cancellation: it simply
|
|
16550
|
+
// did not start. Returning "skip" moves the backtest to the next timeframe.
|
|
16369
16551
|
if (!candles.length) {
|
|
16370
16552
|
return { type: "skip" };
|
|
16371
16553
|
}
|
|
@@ -16407,7 +16589,7 @@ const PROCESS_SCHEDULED_SIGNAL_FN = async function* (self, symbol, when, result,
|
|
|
16407
16589
|
});
|
|
16408
16590
|
}
|
|
16409
16591
|
try {
|
|
16410
|
-
const firstResult = await BACKTEST_FN(self, symbol, candles, when, context, { signalId: signal.id });
|
|
16592
|
+
const firstResult = await BACKTEST_FN(self, symbol, candles, frameEndTime, when, context, { signalId: signal.id });
|
|
16411
16593
|
if ("__error__" in firstResult) {
|
|
16412
16594
|
console.error(`backtestLogicPrivateService scheduled signal: backtest failed, stopping backtest symbol=${symbol} signalId=${signal.id} reason=${firstResult.reason} message=${firstResult.message}`);
|
|
16413
16595
|
return firstResult;
|
|
@@ -16419,7 +16601,7 @@ const PROCESS_SCHEDULED_SIGNAL_FN = async function* (self, symbol, when, result,
|
|
|
16419
16601
|
}
|
|
16420
16602
|
if (backtestResult.action === "active" && signal.minuteEstimatedTime === Infinity) {
|
|
16421
16603
|
const bufferMs = bufferMinutes * 60000;
|
|
16422
|
-
const chunkResult = await RUN_INFINITY_CHUNK_LOOP_FN(self, symbol, when, context, backtestResult, bufferMs, signal.id);
|
|
16604
|
+
const chunkResult = await RUN_INFINITY_CHUNK_LOOP_FN(self, symbol, when, context, backtestResult, bufferMs, signal.id, frameEndTime);
|
|
16423
16605
|
if ("__error__" in chunkResult) {
|
|
16424
16606
|
console.error(`backtestLogicPrivateService scheduled signal: infinity chunk loop failed, stopping backtest symbol=${symbol} signalId=${signal.id} reason=${chunkResult.reason} message=${chunkResult.message}`);
|
|
16425
16607
|
return chunkResult;
|
|
@@ -16449,7 +16631,7 @@ const PROCESS_SCHEDULED_SIGNAL_FN = async function* (self, symbol, when, result,
|
|
|
16449
16631
|
yield backtestResult;
|
|
16450
16632
|
return { type: "closed", previousEventTimestamp: newTimestamp, closeTimestamp: backtestResult.closeTimestamp, shouldStop };
|
|
16451
16633
|
};
|
|
16452
|
-
const RUN_OPENED_CHUNK_LOOP_FN = async (self, symbol, when, context, bufferStartTime, bufferMs, signalId) => {
|
|
16634
|
+
const RUN_OPENED_CHUNK_LOOP_FN = async (self, symbol, when, context, bufferStartTime, bufferMs, signalId, frameEndTime) => {
|
|
16453
16635
|
const CHUNK = GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST;
|
|
16454
16636
|
let chunkStart = bufferStartTime;
|
|
16455
16637
|
let lastChunkCandles = [];
|
|
@@ -16470,29 +16652,14 @@ const RUN_OPENED_CHUNK_LOOP_FN = async (self, symbol, when, context, bufferStart
|
|
|
16470
16652
|
await errorEmitter.next(new Error(message));
|
|
16471
16653
|
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "RUN_OPENED_CHUNK_LOOP_FN", message };
|
|
16472
16654
|
}
|
|
16473
|
-
await self
|
|
16474
|
-
const result = await BACKTEST_FN(self, symbol, lastChunkCandles, when, context, { signalId });
|
|
16475
|
-
if ("__error__" in result) {
|
|
16476
|
-
return result;
|
|
16477
|
-
}
|
|
16478
|
-
if (result.action === "active") {
|
|
16479
|
-
const message = `signal ${signalId} still active after closePending`;
|
|
16480
|
-
console.error(`backtestLogicPrivateService RUN_OPENED_CHUNK_LOOP_FN: ${message} symbol=${symbol}`);
|
|
16481
|
-
self.loggerService.warn("backtestLogicPrivateService opened infinity: signal still active after closePending", {
|
|
16482
|
-
symbol,
|
|
16483
|
-
signalId,
|
|
16484
|
-
});
|
|
16485
|
-
await errorEmitter.next(new Error(message));
|
|
16486
|
-
return { type: "error", __error__: SYMBOL_FN_ERROR, reason: "RUN_OPENED_CHUNK_LOOP_FN", message };
|
|
16487
|
-
}
|
|
16488
|
-
return result;
|
|
16655
|
+
return await CLOSE_PENDING_FN(self, symbol, context, lastChunkCandles, frameEndTime, when, signalId);
|
|
16489
16656
|
}
|
|
16490
16657
|
self.loggerService.info("backtestLogicPrivateService candles fetched", {
|
|
16491
16658
|
symbol,
|
|
16492
16659
|
signalId,
|
|
16493
16660
|
candlesCount: chunkCandles.length,
|
|
16494
16661
|
});
|
|
16495
|
-
const chunkResult = await BACKTEST_FN(self, symbol, chunkCandles, when, context, { signalId });
|
|
16662
|
+
const chunkResult = await BACKTEST_FN(self, symbol, chunkCandles, frameEndTime, when, context, { signalId });
|
|
16496
16663
|
if ("__error__" in chunkResult) {
|
|
16497
16664
|
return chunkResult;
|
|
16498
16665
|
}
|
|
@@ -16503,7 +16670,7 @@ const RUN_OPENED_CHUNK_LOOP_FN = async (self, symbol, when, context, bufferStart
|
|
|
16503
16670
|
chunkStart = new Date(chunkResult._backtestLastTimestamp + 60000 - bufferMs);
|
|
16504
16671
|
}
|
|
16505
16672
|
};
|
|
16506
|
-
const PROCESS_OPENED_SIGNAL_FN = async function* (self, symbol, when, result, previousEventTimestamp) {
|
|
16673
|
+
const PROCESS_OPENED_SIGNAL_FN = async function* (self, symbol, when, result, previousEventTimestamp, frameEndTime) {
|
|
16507
16674
|
const signalStartTime = performance.now();
|
|
16508
16675
|
const signal = result.signal;
|
|
16509
16676
|
self.loggerService.info("backtestLogicPrivateService signal opened", {
|
|
@@ -16534,7 +16701,7 @@ const PROCESS_OPENED_SIGNAL_FN = async function* (self, symbol, when, result, pr
|
|
|
16534
16701
|
signalId: signal.id,
|
|
16535
16702
|
candlesCount: candles.length,
|
|
16536
16703
|
});
|
|
16537
|
-
const firstResult = await BACKTEST_FN(self, symbol, candles, when, context, { signalId: signal.id });
|
|
16704
|
+
const firstResult = await BACKTEST_FN(self, symbol, candles, frameEndTime, when, context, { signalId: signal.id });
|
|
16538
16705
|
if ("__error__" in firstResult) {
|
|
16539
16706
|
console.error(`backtestLogicPrivateService opened signal: backtest failed, stopping backtest symbol=${symbol} signalId=${signal.id} reason=${firstResult.reason} message=${firstResult.message}`);
|
|
16540
16707
|
return firstResult;
|
|
@@ -16543,7 +16710,7 @@ const PROCESS_OPENED_SIGNAL_FN = async function* (self, symbol, when, result, pr
|
|
|
16543
16710
|
}
|
|
16544
16711
|
else {
|
|
16545
16712
|
const bufferMs = bufferMinutes * 60000;
|
|
16546
|
-
const chunkResult = await RUN_OPENED_CHUNK_LOOP_FN(self, symbol, when, context, bufferStartTime, bufferMs, signal.id);
|
|
16713
|
+
const chunkResult = await RUN_OPENED_CHUNK_LOOP_FN(self, symbol, when, context, bufferStartTime, bufferMs, signal.id, frameEndTime);
|
|
16547
16714
|
if ("__error__" in chunkResult) {
|
|
16548
16715
|
console.error(`backtestLogicPrivateService opened signal: chunk loop failed, stopping backtest symbol=${symbol} signalId=${signal.id} reason=${chunkResult.reason} message=${chunkResult.message}`);
|
|
16549
16716
|
return chunkResult;
|
|
@@ -16609,86 +16776,103 @@ class BacktestLogicPrivateService {
|
|
|
16609
16776
|
symbol,
|
|
16610
16777
|
});
|
|
16611
16778
|
const backtestStartTime = performance.now();
|
|
16779
|
+
let _fatalError = null;
|
|
16780
|
+
let previousEventTimestamp = null;
|
|
16612
16781
|
const timeframes = await this.frameCoreService.getTimeframe(symbol, this.methodContextService.context.frameName);
|
|
16613
16782
|
const totalFrames = timeframes.length;
|
|
16783
|
+
let frameEndTime = timeframes[totalFrames - 1].getTime();
|
|
16614
16784
|
let i = 0;
|
|
16615
|
-
|
|
16616
|
-
|
|
16617
|
-
|
|
16618
|
-
|
|
16619
|
-
|
|
16620
|
-
|
|
16621
|
-
break;
|
|
16622
|
-
}
|
|
16623
|
-
const result = await TICK_FN(this, symbol, when);
|
|
16624
|
-
if ("__error__" in result) {
|
|
16625
|
-
break;
|
|
16626
|
-
}
|
|
16627
|
-
if (result.action === "idle" &&
|
|
16628
|
-
await functoolsKit.and(Promise.resolve(true), this.strategyCoreService.getStopped(true, symbol, {
|
|
16629
|
-
strategyName: this.methodContextService.context.strategyName,
|
|
16630
|
-
exchangeName: this.methodContextService.context.exchangeName,
|
|
16631
|
-
frameName: this.methodContextService.context.frameName,
|
|
16632
|
-
}))) {
|
|
16633
|
-
this.loggerService.info("backtestLogicPrivateService stopped by user request (idle state)", {
|
|
16634
|
-
symbol,
|
|
16635
|
-
when: when.toISOString(),
|
|
16636
|
-
processedFrames: i,
|
|
16637
|
-
totalFrames,
|
|
16638
|
-
});
|
|
16639
|
-
break;
|
|
16640
|
-
}
|
|
16641
|
-
if (result.action === "scheduled") {
|
|
16642
|
-
yield result;
|
|
16643
|
-
const r = yield* PROCESS_SCHEDULED_SIGNAL_FN(this, symbol, when, result, previousEventTimestamp);
|
|
16644
|
-
if (r.type === "error") {
|
|
16785
|
+
try {
|
|
16786
|
+
while (i < timeframes.length) {
|
|
16787
|
+
const timeframeStartTime = performance.now();
|
|
16788
|
+
const when = timeframes[i];
|
|
16789
|
+
await EMIT_PROGRESS_FN(this, symbol, totalFrames, i);
|
|
16790
|
+
if (await CHECK_STOPPED_FN(this, symbol, "before tick", { when: when.toISOString(), processedFrames: i, totalFrames })) {
|
|
16645
16791
|
break;
|
|
16646
16792
|
}
|
|
16647
|
-
|
|
16648
|
-
|
|
16649
|
-
|
|
16650
|
-
|
|
16651
|
-
}
|
|
16652
|
-
if (r.shouldStop) {
|
|
16653
|
-
break;
|
|
16654
|
-
}
|
|
16793
|
+
const result = await TICK_FN(this, symbol, when);
|
|
16794
|
+
if ("__error__" in result) {
|
|
16795
|
+
_fatalError = new Error(`[${result.reason}] ${result.message}`);
|
|
16796
|
+
break;
|
|
16655
16797
|
}
|
|
16656
|
-
|
|
16657
|
-
|
|
16658
|
-
|
|
16659
|
-
|
|
16660
|
-
|
|
16798
|
+
if (result.action === "idle" &&
|
|
16799
|
+
await functoolsKit.and(Promise.resolve(true), this.strategyCoreService.getStopped(true, symbol, {
|
|
16800
|
+
strategyName: this.methodContextService.context.strategyName,
|
|
16801
|
+
exchangeName: this.methodContextService.context.exchangeName,
|
|
16802
|
+
frameName: this.methodContextService.context.frameName,
|
|
16803
|
+
}))) {
|
|
16804
|
+
this.loggerService.info("backtestLogicPrivateService stopped by user request (idle state)", {
|
|
16805
|
+
symbol,
|
|
16806
|
+
when: when.toISOString(),
|
|
16807
|
+
processedFrames: i,
|
|
16808
|
+
totalFrames,
|
|
16809
|
+
});
|
|
16661
16810
|
break;
|
|
16662
16811
|
}
|
|
16663
|
-
if (
|
|
16664
|
-
|
|
16665
|
-
|
|
16666
|
-
|
|
16812
|
+
if (result.action === "scheduled") {
|
|
16813
|
+
yield result;
|
|
16814
|
+
const r = yield* PROCESS_SCHEDULED_SIGNAL_FN(this, symbol, when, result, previousEventTimestamp, frameEndTime);
|
|
16815
|
+
if (r.type === "error") {
|
|
16816
|
+
_fatalError = new Error(`[${r.reason}] ${r.message}`);
|
|
16817
|
+
break;
|
|
16667
16818
|
}
|
|
16668
|
-
if (r.
|
|
16819
|
+
if (r.type === "closed") {
|
|
16820
|
+
previousEventTimestamp = r.previousEventTimestamp;
|
|
16821
|
+
while (i < timeframes.length && timeframes[i].getTime() < r.closeTimestamp) {
|
|
16822
|
+
i++;
|
|
16823
|
+
}
|
|
16824
|
+
if (r.shouldStop) {
|
|
16825
|
+
break;
|
|
16826
|
+
}
|
|
16827
|
+
}
|
|
16828
|
+
}
|
|
16829
|
+
if (result.action === "opened") {
|
|
16830
|
+
yield result;
|
|
16831
|
+
const r = yield* PROCESS_OPENED_SIGNAL_FN(this, symbol, when, result, previousEventTimestamp, frameEndTime);
|
|
16832
|
+
if (r.type === "error") {
|
|
16833
|
+
_fatalError = new Error(`[${r.reason}] ${r.message}`);
|
|
16669
16834
|
break;
|
|
16670
16835
|
}
|
|
16836
|
+
if (r.type === "closed") {
|
|
16837
|
+
previousEventTimestamp = r.previousEventTimestamp;
|
|
16838
|
+
while (i < timeframes.length && timeframes[i].getTime() < r.closeTimestamp) {
|
|
16839
|
+
i++;
|
|
16840
|
+
}
|
|
16841
|
+
if (r.shouldStop) {
|
|
16842
|
+
break;
|
|
16843
|
+
}
|
|
16844
|
+
}
|
|
16671
16845
|
}
|
|
16846
|
+
previousEventTimestamp = await EMIT_TIMEFRAME_PERFORMANCE_FN(this, symbol, timeframeStartTime, previousEventTimestamp);
|
|
16847
|
+
i++;
|
|
16672
16848
|
}
|
|
16673
|
-
|
|
16674
|
-
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
|
|
16678
|
-
|
|
16679
|
-
|
|
16680
|
-
|
|
16681
|
-
|
|
16682
|
-
|
|
16683
|
-
|
|
16684
|
-
|
|
16685
|
-
|
|
16686
|
-
|
|
16687
|
-
|
|
16688
|
-
|
|
16689
|
-
|
|
16690
|
-
|
|
16691
|
-
|
|
16849
|
+
// Emit final progress event (100%)
|
|
16850
|
+
await EMIT_PROGRESS_FN(this, symbol, totalFrames, totalFrames);
|
|
16851
|
+
// Track total backtest duration
|
|
16852
|
+
const backtestEndTime = performance.now();
|
|
16853
|
+
const currentTimestamp = Date.now();
|
|
16854
|
+
await performanceEmitter.next({
|
|
16855
|
+
timestamp: currentTimestamp,
|
|
16856
|
+
previousTimestamp: previousEventTimestamp,
|
|
16857
|
+
metricType: "backtest_total",
|
|
16858
|
+
duration: backtestEndTime - backtestStartTime,
|
|
16859
|
+
strategyName: this.methodContextService.context.strategyName,
|
|
16860
|
+
exchangeName: this.methodContextService.context.exchangeName,
|
|
16861
|
+
frameName: this.methodContextService.context.frameName,
|
|
16862
|
+
symbol,
|
|
16863
|
+
backtest: true,
|
|
16864
|
+
});
|
|
16865
|
+
}
|
|
16866
|
+
catch (error) {
|
|
16867
|
+
_fatalError = error;
|
|
16868
|
+
}
|
|
16869
|
+
finally {
|
|
16870
|
+
if (_fatalError !== null) {
|
|
16871
|
+
console.error(`[BacktestLogicPrivateService] Fatal error — backtest sequence broken for symbol=${symbol} ` +
|
|
16872
|
+
`strategy=${this.methodContextService.context.strategyName}`, _fatalError);
|
|
16873
|
+
process.exit(-1);
|
|
16874
|
+
}
|
|
16875
|
+
}
|
|
16692
16876
|
}
|
|
16693
16877
|
}
|
|
16694
16878
|
|
|
@@ -16705,6 +16889,8 @@ const INTERVAL_MINUTES$3 = {
|
|
|
16705
16889
|
"4h": 240,
|
|
16706
16890
|
"6h": 360,
|
|
16707
16891
|
"8h": 480,
|
|
16892
|
+
"1d": 1440,
|
|
16893
|
+
"1w": 10080,
|
|
16708
16894
|
};
|
|
16709
16895
|
const createEmitter = functoolsKit.memoize(([interval]) => `${interval}`, (interval) => {
|
|
16710
16896
|
const tickSubject = new functoolsKit.Subject();
|
|
@@ -31541,6 +31727,8 @@ const INTERVAL_MINUTES$2 = {
|
|
|
31541
31727
|
"4h": 240,
|
|
31542
31728
|
"6h": 360,
|
|
31543
31729
|
"8h": 480,
|
|
31730
|
+
"1d": 1440,
|
|
31731
|
+
"1w": 10080,
|
|
31544
31732
|
};
|
|
31545
31733
|
/**
|
|
31546
31734
|
* Aligns timestamp down to the nearest interval boundary.
|
|
@@ -32290,6 +32478,8 @@ const INTERVAL_MINUTES$1 = {
|
|
|
32290
32478
|
"4h": 240,
|
|
32291
32479
|
"6h": 360,
|
|
32292
32480
|
"8h": 480,
|
|
32481
|
+
"1d": 1440,
|
|
32482
|
+
"1w": 10080,
|
|
32293
32483
|
};
|
|
32294
32484
|
const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
|
|
32295
32485
|
const intervalMs = intervalMinutes * MS_PER_MINUTE$1;
|
|
@@ -33344,54 +33534,144 @@ const BROKER_BASE_METHOD_NAME_ON_TRAILING_STOP = "BrokerBase.onTrailingStopCommi
|
|
|
33344
33534
|
const BROKER_BASE_METHOD_NAME_ON_TRAILING_TAKE = "BrokerBase.onTrailingTakeCommit";
|
|
33345
33535
|
const BROKER_BASE_METHOD_NAME_ON_BREAKEVEN = "BrokerBase.onBreakevenCommit";
|
|
33346
33536
|
const BROKER_BASE_METHOD_NAME_ON_AVERAGE_BUY = "BrokerBase.onAverageBuyCommit";
|
|
33537
|
+
/**
|
|
33538
|
+
* Wrapper around a `Partial<IBroker>` adapter instance.
|
|
33539
|
+
*
|
|
33540
|
+
* Implements the full `IBroker` interface but guards every method call —
|
|
33541
|
+
* if the underlying adapter does not implement a given method, an error is thrown.
|
|
33542
|
+
* `waitForInit` is the only exception: it is silently skipped when not implemented.
|
|
33543
|
+
*
|
|
33544
|
+
* Created internally by `BrokerAdapter.useBrokerAdapter` and stored as
|
|
33545
|
+
* `_brokerInstance`. All `BrokerAdapter.commit*` methods delegate here
|
|
33546
|
+
* after backtest-mode and enable-state checks pass.
|
|
33547
|
+
*/
|
|
33347
33548
|
class BrokerProxy {
|
|
33348
33549
|
constructor(_instance) {
|
|
33349
33550
|
this._instance = _instance;
|
|
33551
|
+
/**
|
|
33552
|
+
* Calls `waitForInit` on the underlying adapter exactly once (singleshot).
|
|
33553
|
+
* If the adapter does not implement `waitForInit`, the call is silently skipped.
|
|
33554
|
+
*
|
|
33555
|
+
* @returns Resolves when initialization is complete (or immediately if not implemented).
|
|
33556
|
+
*/
|
|
33350
33557
|
this.waitForInit = functoolsKit.singleshot(async () => {
|
|
33351
33558
|
if (this._instance.waitForInit) {
|
|
33352
33559
|
await this._instance.waitForInit();
|
|
33560
|
+
return;
|
|
33353
33561
|
}
|
|
33354
33562
|
});
|
|
33355
33563
|
}
|
|
33564
|
+
/**
|
|
33565
|
+
* Forwards a signal-open event to the underlying adapter.
|
|
33566
|
+
* Throws if the adapter does not implement `onSignalOpenCommit`.
|
|
33567
|
+
*
|
|
33568
|
+
* @param payload - Signal open details: symbol, cost, position, prices, context, backtest flag.
|
|
33569
|
+
* @throws {Error} If the adapter does not implement `onSignalOpenCommit`.
|
|
33570
|
+
*/
|
|
33356
33571
|
async onSignalOpenCommit(payload) {
|
|
33357
33572
|
if (this._instance.onSignalOpenCommit) {
|
|
33358
33573
|
await this._instance.onSignalOpenCommit(payload);
|
|
33574
|
+
return;
|
|
33359
33575
|
}
|
|
33576
|
+
throw new Error("BrokerProxy onSignalOpenCommit is not implemented");
|
|
33360
33577
|
}
|
|
33578
|
+
/**
|
|
33579
|
+
* Forwards a signal-close event to the underlying adapter.
|
|
33580
|
+
* Throws if the adapter does not implement `onSignalCloseCommit`.
|
|
33581
|
+
*
|
|
33582
|
+
* @param payload - Signal close details: symbol, cost, position, currentPrice, pnl, context, backtest flag.
|
|
33583
|
+
* @throws {Error} If the adapter does not implement `onSignalCloseCommit`.
|
|
33584
|
+
*/
|
|
33361
33585
|
async onSignalCloseCommit(payload) {
|
|
33362
33586
|
if (this._instance.onSignalCloseCommit) {
|
|
33363
33587
|
await this._instance.onSignalCloseCommit(payload);
|
|
33588
|
+
return;
|
|
33364
33589
|
}
|
|
33590
|
+
throw new Error("BrokerProxy onSignalCloseCommit is not implemented");
|
|
33365
33591
|
}
|
|
33592
|
+
/**
|
|
33593
|
+
* Forwards a partial-profit close event to the underlying adapter.
|
|
33594
|
+
* Throws if the adapter does not implement `onPartialProfitCommit`.
|
|
33595
|
+
*
|
|
33596
|
+
* @param payload - Partial profit details: symbol, percentToClose, cost, currentPrice, context, backtest flag.
|
|
33597
|
+
* @throws {Error} If the adapter does not implement `onPartialProfitCommit`.
|
|
33598
|
+
*/
|
|
33366
33599
|
async onPartialProfitCommit(payload) {
|
|
33367
33600
|
if (this._instance.onPartialProfitCommit) {
|
|
33368
33601
|
await this._instance.onPartialProfitCommit(payload);
|
|
33602
|
+
return;
|
|
33369
33603
|
}
|
|
33604
|
+
throw new Error("BrokerProxy onPartialProfitCommit is not implemented");
|
|
33370
33605
|
}
|
|
33606
|
+
/**
|
|
33607
|
+
* Forwards a partial-loss close event to the underlying adapter.
|
|
33608
|
+
* Throws if the adapter does not implement `onPartialLossCommit`.
|
|
33609
|
+
*
|
|
33610
|
+
* @param payload - Partial loss details: symbol, percentToClose, cost, currentPrice, context, backtest flag.
|
|
33611
|
+
* @throws {Error} If the adapter does not implement `onPartialLossCommit`.
|
|
33612
|
+
*/
|
|
33371
33613
|
async onPartialLossCommit(payload) {
|
|
33372
33614
|
if (this._instance.onPartialLossCommit) {
|
|
33373
33615
|
await this._instance.onPartialLossCommit(payload);
|
|
33616
|
+
return;
|
|
33374
33617
|
}
|
|
33618
|
+
throw new Error("BrokerProxy onPartialLossCommit is not implemented");
|
|
33375
33619
|
}
|
|
33620
|
+
/**
|
|
33621
|
+
* Forwards a trailing stop-loss update event to the underlying adapter.
|
|
33622
|
+
* Throws if the adapter does not implement `onTrailingStopCommit`.
|
|
33623
|
+
*
|
|
33624
|
+
* @param payload - Trailing stop details: symbol, percentShift, currentPrice, newStopLossPrice, context, backtest flag.
|
|
33625
|
+
* @throws {Error} If the adapter does not implement `onTrailingStopCommit`.
|
|
33626
|
+
*/
|
|
33376
33627
|
async onTrailingStopCommit(payload) {
|
|
33377
33628
|
if (this._instance.onTrailingStopCommit) {
|
|
33378
33629
|
await this._instance.onTrailingStopCommit(payload);
|
|
33630
|
+
return;
|
|
33379
33631
|
}
|
|
33632
|
+
throw new Error("BrokerProxy onTrailingStopCommit is not implemented");
|
|
33380
33633
|
}
|
|
33634
|
+
/**
|
|
33635
|
+
* Forwards a trailing take-profit update event to the underlying adapter.
|
|
33636
|
+
* Throws if the adapter does not implement `onTrailingTakeCommit`.
|
|
33637
|
+
*
|
|
33638
|
+
* @param payload - Trailing take details: symbol, percentShift, currentPrice, newTakeProfitPrice, context, backtest flag.
|
|
33639
|
+
* @throws {Error} If the adapter does not implement `onTrailingTakeCommit`.
|
|
33640
|
+
*/
|
|
33381
33641
|
async onTrailingTakeCommit(payload) {
|
|
33382
33642
|
if (this._instance.onTrailingTakeCommit) {
|
|
33383
33643
|
await this._instance.onTrailingTakeCommit(payload);
|
|
33644
|
+
return;
|
|
33384
33645
|
}
|
|
33646
|
+
throw new Error("BrokerProxy onTrailingTakeCommit is not implemented");
|
|
33385
33647
|
}
|
|
33648
|
+
/**
|
|
33649
|
+
* Forwards a breakeven event to the underlying adapter.
|
|
33650
|
+
* Throws if the adapter does not implement `onBreakevenCommit`.
|
|
33651
|
+
*
|
|
33652
|
+
* @param payload - Breakeven details: symbol, currentPrice, newStopLossPrice (= effectivePriceOpen), newTakeProfitPrice, context, backtest flag.
|
|
33653
|
+
* @throws {Error} If the adapter does not implement `onBreakevenCommit`.
|
|
33654
|
+
*/
|
|
33386
33655
|
async onBreakevenCommit(payload) {
|
|
33387
33656
|
if (this._instance.onBreakevenCommit) {
|
|
33388
33657
|
await this._instance.onBreakevenCommit(payload);
|
|
33658
|
+
return;
|
|
33389
33659
|
}
|
|
33660
|
+
throw new Error("BrokerProxy onBreakevenCommit is not implemented");
|
|
33390
33661
|
}
|
|
33662
|
+
/**
|
|
33663
|
+
* Forwards a DCA average-buy entry event to the underlying adapter.
|
|
33664
|
+
* Throws if the adapter does not implement `onAverageBuyCommit`.
|
|
33665
|
+
*
|
|
33666
|
+
* @param payload - Average buy details: symbol, currentPrice, cost, context, backtest flag.
|
|
33667
|
+
* @throws {Error} If the adapter does not implement `onAverageBuyCommit`.
|
|
33668
|
+
*/
|
|
33391
33669
|
async onAverageBuyCommit(payload) {
|
|
33392
33670
|
if (this._instance.onAverageBuyCommit) {
|
|
33393
33671
|
await this._instance.onAverageBuyCommit(payload);
|
|
33672
|
+
return;
|
|
33394
33673
|
}
|
|
33674
|
+
throw new Error("BrokerProxy onAverageBuyCommit is not implemented");
|
|
33395
33675
|
}
|
|
33396
33676
|
}
|
|
33397
33677
|
/**
|
|
@@ -43163,7 +43443,7 @@ class MemoryLocalInstance {
|
|
|
43163
43443
|
* @param value - Value to store and index
|
|
43164
43444
|
* @param index - Optional BM25 index string; defaults to JSON.stringify(value)
|
|
43165
43445
|
*/
|
|
43166
|
-
async writeMemory(memoryId, value,
|
|
43446
|
+
async writeMemory(memoryId, value, description) {
|
|
43167
43447
|
bt.loggerService.debug(MEMORY_LOCAL_INSTANCE_METHOD_NAME_WRITE, {
|
|
43168
43448
|
signalId: this.signalId,
|
|
43169
43449
|
bucketName: this.bucketName,
|
|
@@ -43172,7 +43452,7 @@ class MemoryLocalInstance {
|
|
|
43172
43452
|
this._index.upsert({
|
|
43173
43453
|
id: memoryId,
|
|
43174
43454
|
content: value,
|
|
43175
|
-
index:
|
|
43455
|
+
index: description,
|
|
43176
43456
|
priority: Date.now(),
|
|
43177
43457
|
});
|
|
43178
43458
|
}
|
|
@@ -43471,7 +43751,7 @@ class MemoryAdapter {
|
|
|
43471
43751
|
* @param dto.value - Value to store
|
|
43472
43752
|
* @param dto.signalId - Signal identifier
|
|
43473
43753
|
* @param dto.bucketName - Bucket name
|
|
43474
|
-
* @param dto.
|
|
43754
|
+
* @param dto.description - Optional BM25 index string; defaults to JSON.stringify(value)
|
|
43475
43755
|
*/
|
|
43476
43756
|
this.writeMemory = async (dto) => {
|
|
43477
43757
|
if (!this.enable.hasValue()) {
|
|
@@ -43486,7 +43766,7 @@ class MemoryAdapter {
|
|
|
43486
43766
|
const isInitial = !this.getInstance.has(key);
|
|
43487
43767
|
const instance = this.getInstance(dto.signalId, dto.bucketName);
|
|
43488
43768
|
await instance.waitForInit(isInitial);
|
|
43489
|
-
return await instance.writeMemory(dto.memoryId, dto.value, dto.
|
|
43769
|
+
return await instance.writeMemory(dto.memoryId, dto.value, dto.description);
|
|
43490
43770
|
};
|
|
43491
43771
|
/**
|
|
43492
43772
|
* Search memory using BM25 full-text scoring.
|
|
@@ -43637,7 +43917,7 @@ const REMOVE_MEMORY_METHOD_NAME = "memory.removeMemory";
|
|
|
43637
43917
|
* ```
|
|
43638
43918
|
*/
|
|
43639
43919
|
async function writeMemory(dto) {
|
|
43640
|
-
const { bucketName, memoryId, value } = dto;
|
|
43920
|
+
const { bucketName, memoryId, value, description } = dto;
|
|
43641
43921
|
bt.loggerService.info(WRITE_MEMORY_METHOD_NAME, {
|
|
43642
43922
|
bucketName,
|
|
43643
43923
|
memoryId,
|
|
@@ -43661,6 +43941,7 @@ async function writeMemory(dto) {
|
|
|
43661
43941
|
value,
|
|
43662
43942
|
signalId: signal.id,
|
|
43663
43943
|
bucketName,
|
|
43944
|
+
description,
|
|
43664
43945
|
});
|
|
43665
43946
|
}
|
|
43666
43947
|
/**
|
|
@@ -44104,7 +44385,7 @@ class DumpMemoryInstance {
|
|
|
44104
44385
|
bucketName: this.bucketName,
|
|
44105
44386
|
signalId: this.signalId,
|
|
44106
44387
|
value: { messages },
|
|
44107
|
-
|
|
44388
|
+
description,
|
|
44108
44389
|
});
|
|
44109
44390
|
}
|
|
44110
44391
|
/**
|
|
@@ -44125,7 +44406,7 @@ class DumpMemoryInstance {
|
|
|
44125
44406
|
bucketName: this.bucketName,
|
|
44126
44407
|
signalId: this.signalId,
|
|
44127
44408
|
value: record,
|
|
44128
|
-
|
|
44409
|
+
description,
|
|
44129
44410
|
});
|
|
44130
44411
|
}
|
|
44131
44412
|
/**
|
|
@@ -44147,7 +44428,7 @@ class DumpMemoryInstance {
|
|
|
44147
44428
|
bucketName: this.bucketName,
|
|
44148
44429
|
signalId: this.signalId,
|
|
44149
44430
|
value: { rows },
|
|
44150
|
-
|
|
44431
|
+
description,
|
|
44151
44432
|
});
|
|
44152
44433
|
}
|
|
44153
44434
|
/**
|
|
@@ -44168,7 +44449,7 @@ class DumpMemoryInstance {
|
|
|
44168
44449
|
bucketName: this.bucketName,
|
|
44169
44450
|
signalId: this.signalId,
|
|
44170
44451
|
value: { content },
|
|
44171
|
-
|
|
44452
|
+
description,
|
|
44172
44453
|
});
|
|
44173
44454
|
}
|
|
44174
44455
|
/**
|
|
@@ -44189,7 +44470,7 @@ class DumpMemoryInstance {
|
|
|
44189
44470
|
bucketName: this.bucketName,
|
|
44190
44471
|
signalId: this.signalId,
|
|
44191
44472
|
value: { content },
|
|
44192
|
-
|
|
44473
|
+
description,
|
|
44193
44474
|
});
|
|
44194
44475
|
}
|
|
44195
44476
|
/**
|
|
@@ -44211,7 +44492,7 @@ class DumpMemoryInstance {
|
|
|
44211
44492
|
bucketName: this.bucketName,
|
|
44212
44493
|
signalId: this.signalId,
|
|
44213
44494
|
value: json,
|
|
44214
|
-
|
|
44495
|
+
description,
|
|
44215
44496
|
});
|
|
44216
44497
|
}
|
|
44217
44498
|
/** Releases resources held by this instance. */
|
|
@@ -50634,6 +50915,8 @@ const INTERVAL_MINUTES = {
|
|
|
50634
50915
|
"4h": 240,
|
|
50635
50916
|
"6h": 360,
|
|
50636
50917
|
"8h": 480,
|
|
50918
|
+
"1d": 1440,
|
|
50919
|
+
"1w": 10080,
|
|
50637
50920
|
};
|
|
50638
50921
|
/**
|
|
50639
50922
|
* Aligns timestamp down to the nearest interval boundary.
|
|
@@ -51708,6 +51991,119 @@ const percentValue = (yesterdayValue, todayValue) => {
|
|
|
51708
51991
|
return yesterdayValue / todayValue - 1;
|
|
51709
51992
|
};
|
|
51710
51993
|
|
|
51994
|
+
/**
|
|
51995
|
+
* Validates ISignalDto returned by getSignal, branching on the same logic as ClientStrategy GET_SIGNAL_FN.
|
|
51996
|
+
*
|
|
51997
|
+
* When priceOpen is provided:
|
|
51998
|
+
* - If currentPrice already reached priceOpen (shouldActivateImmediately) →
|
|
51999
|
+
* validates as pending: currentPrice must be between SL and TP
|
|
52000
|
+
* - Otherwise → validates as scheduled: priceOpen must be between SL and TP
|
|
52001
|
+
*
|
|
52002
|
+
* When priceOpen is absent:
|
|
52003
|
+
* - Validates as pending: currentPrice must be between SL and TP
|
|
52004
|
+
*
|
|
52005
|
+
* Checks:
|
|
52006
|
+
* - currentPrice is a finite positive number
|
|
52007
|
+
* - Common signal fields via validateCommonSignal (position, prices, TP/SL relationships, minuteEstimatedTime)
|
|
52008
|
+
* - Position-specific immediate-close protection (pending) or activation-close protection (scheduled)
|
|
52009
|
+
*
|
|
52010
|
+
* @param signal - Signal DTO returned by getSignal
|
|
52011
|
+
* @param currentPrice - Current market price at the moment of signal creation
|
|
52012
|
+
* @returns true if signal is valid, false if validation errors were found (errors logged to console.error)
|
|
52013
|
+
*/
|
|
52014
|
+
const validateSignal = (signal, currentPrice) => {
|
|
52015
|
+
const errors = [];
|
|
52016
|
+
// ЗАЩИТА ОТ NaN/Infinity: currentPrice должна быть конечным числом
|
|
52017
|
+
{
|
|
52018
|
+
if (typeof currentPrice !== "number") {
|
|
52019
|
+
errors.push(`currentPrice must be a number type, got ${currentPrice} (${typeof currentPrice})`);
|
|
52020
|
+
}
|
|
52021
|
+
if (!isFinite(currentPrice)) {
|
|
52022
|
+
errors.push(`currentPrice must be a finite number, got ${currentPrice} (${typeof currentPrice})`);
|
|
52023
|
+
}
|
|
52024
|
+
if (isFinite(currentPrice) && currentPrice <= 0) {
|
|
52025
|
+
errors.push(`currentPrice must be positive, got ${currentPrice}`);
|
|
52026
|
+
}
|
|
52027
|
+
}
|
|
52028
|
+
if (errors.length > 0) {
|
|
52029
|
+
console.error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
52030
|
+
return false;
|
|
52031
|
+
}
|
|
52032
|
+
try {
|
|
52033
|
+
validateCommonSignal(signal);
|
|
52034
|
+
}
|
|
52035
|
+
catch (error) {
|
|
52036
|
+
console.error(functoolsKit.getErrorMessage(error));
|
|
52037
|
+
return false;
|
|
52038
|
+
}
|
|
52039
|
+
// Определяем режим валидации по той же логике что в GET_SIGNAL_FN:
|
|
52040
|
+
// - нет priceOpen → pending (открывается по currentPrice)
|
|
52041
|
+
// - priceOpen задан и уже достигнут (shouldActivateImmediately) → pending
|
|
52042
|
+
// - priceOpen задан и ещё не достигнут → scheduled
|
|
52043
|
+
const hasPriceOpen = signal.priceOpen !== undefined;
|
|
52044
|
+
const shouldActivateImmediately = hasPriceOpen && ((signal.position === "long" && currentPrice <= signal.priceOpen) ||
|
|
52045
|
+
(signal.position === "short" && currentPrice >= signal.priceOpen));
|
|
52046
|
+
const isScheduled = hasPriceOpen && !shouldActivateImmediately;
|
|
52047
|
+
if (isScheduled) {
|
|
52048
|
+
// Scheduled: priceOpen должен быть между SL и TP (активация не даст моментального закрытия)
|
|
52049
|
+
if (signal.position === "long") {
|
|
52050
|
+
if (isFinite(signal.priceOpen)) {
|
|
52051
|
+
if (signal.priceOpen <= signal.priceStopLoss) {
|
|
52052
|
+
errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
52053
|
+
`Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
|
|
52054
|
+
}
|
|
52055
|
+
if (signal.priceOpen >= signal.priceTakeProfit) {
|
|
52056
|
+
errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
52057
|
+
`Signal would close immediately on activation. This is logically impossible for LONG position.`);
|
|
52058
|
+
}
|
|
52059
|
+
}
|
|
52060
|
+
}
|
|
52061
|
+
if (signal.position === "short") {
|
|
52062
|
+
if (isFinite(signal.priceOpen)) {
|
|
52063
|
+
if (signal.priceOpen >= signal.priceStopLoss) {
|
|
52064
|
+
errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
52065
|
+
`Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
|
|
52066
|
+
}
|
|
52067
|
+
if (signal.priceOpen <= signal.priceTakeProfit) {
|
|
52068
|
+
errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
52069
|
+
`Signal would close immediately on activation. This is logically impossible for SHORT position.`);
|
|
52070
|
+
}
|
|
52071
|
+
}
|
|
52072
|
+
}
|
|
52073
|
+
}
|
|
52074
|
+
else {
|
|
52075
|
+
// Pending: currentPrice должна быть между SL и TP (позиция не закроется сразу после открытия)
|
|
52076
|
+
if (signal.position === "long") {
|
|
52077
|
+
if (isFinite(currentPrice)) {
|
|
52078
|
+
if (currentPrice <= signal.priceStopLoss) {
|
|
52079
|
+
errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
52080
|
+
`Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
|
|
52081
|
+
}
|
|
52082
|
+
if (currentPrice >= signal.priceTakeProfit) {
|
|
52083
|
+
errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
52084
|
+
`Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
|
|
52085
|
+
}
|
|
52086
|
+
}
|
|
52087
|
+
}
|
|
52088
|
+
if (signal.position === "short") {
|
|
52089
|
+
if (isFinite(currentPrice)) {
|
|
52090
|
+
if (currentPrice >= signal.priceStopLoss) {
|
|
52091
|
+
errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
|
|
52092
|
+
`Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
|
|
52093
|
+
}
|
|
52094
|
+
if (currentPrice <= signal.priceTakeProfit) {
|
|
52095
|
+
errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
|
|
52096
|
+
`Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
|
|
52097
|
+
}
|
|
52098
|
+
}
|
|
52099
|
+
}
|
|
52100
|
+
}
|
|
52101
|
+
if (errors.length > 0) {
|
|
52102
|
+
console.error(`Invalid signal for ${signal.position} position:\n${errors.join("\n")}`);
|
|
52103
|
+
}
|
|
52104
|
+
return !errors.length;
|
|
52105
|
+
};
|
|
52106
|
+
|
|
51711
52107
|
exports.ActionBase = ActionBase;
|
|
51712
52108
|
exports.Backtest = Backtest;
|
|
51713
52109
|
exports.Breakeven = Breakeven;
|
|
@@ -51913,6 +52309,10 @@ exports.toProfitLossDto = toProfitLossDto;
|
|
|
51913
52309
|
exports.tpPercentShiftToPrice = tpPercentShiftToPrice;
|
|
51914
52310
|
exports.tpPriceToPercentShift = tpPriceToPercentShift;
|
|
51915
52311
|
exports.validate = validate;
|
|
52312
|
+
exports.validateCommonSignal = validateCommonSignal;
|
|
52313
|
+
exports.validatePendingSignal = validatePendingSignal;
|
|
52314
|
+
exports.validateScheduledSignal = validateScheduledSignal;
|
|
52315
|
+
exports.validateSignal = validateSignal;
|
|
51916
52316
|
exports.waitForCandle = waitForCandle;
|
|
51917
52317
|
exports.warmCandles = warmCandles;
|
|
51918
52318
|
exports.writeMemory = writeMemory;
|