@tradejs/cli 1.0.6 → 1.0.9

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.
@@ -35,17 +35,520 @@ __export(signals_exports, {
35
35
  module.exports = __toCommonJS(signals_exports);
36
36
  var import_config = require("dotenv/config");
37
37
  var import_args = __toESM(require("args"));
38
- var import_progress = __toESM(require("progress"));
38
+ var import_progress2 = __toESM(require("progress"));
39
39
  var import_connectors = require("@tradejs/connectors");
40
- var import_chalk = __toESM(require("chalk"));
40
+ var import_chalk2 = __toESM(require("chalk"));
41
41
  var import_connectors2 = require("@tradejs/node/connectors");
42
42
  var import_cli = require("@tradejs/node/cli");
43
- var import_async = require("@tradejs/core/async");
44
- var import_constants = require("@tradejs/core/constants");
43
+ var import_async2 = require("@tradejs/core/async");
44
+ var import_constants2 = require("@tradejs/core/constants");
45
45
  var import_strategies = require("@tradejs/node/strategies");
46
46
  var import_time = require("@tradejs/core/time");
47
47
  var import_logger = require("@tradejs/infra/logger");
48
48
  var import_redis = require("@tradejs/infra/redis");
49
+
50
+ // src/lib/derivativesContextBackfill.ts
51
+ var import_chalk = __toESM(require("chalk"));
52
+ var import_progress = __toESM(require("progress"));
53
+ var import_async = require("@tradejs/core/async");
54
+ var import_constants = require("@tradejs/core/constants");
55
+ var import_indicators = require("@tradejs/core/indicators");
56
+ var import_timescale = require("@tradejs/infra/timescale");
57
+ var import_userSettings = require("@tradejs/infra/userSettings");
58
+ var HOUR_MS = 60 * 60 * 1e3;
59
+ var DAY_MS = 24 * HOUR_MS;
60
+ var DEFAULT_LOOKBACK_HOURS = 48;
61
+ var DEFAULT_INTERVALS = ["15m", "1h"];
62
+ var coinalyzeIntervalMap = {
63
+ "15m": "15min",
64
+ "1h": "1hour"
65
+ };
66
+ var lastRequestTs = 0;
67
+ var asInt = (value, fallback) => {
68
+ const parsed = Number.parseInt(String(value ?? ""), 10);
69
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
70
+ };
71
+ var parseList = (value) => String(value ?? "").split(",").map((item) => item.trim().toUpperCase()).filter(Boolean);
72
+ var normalizeSymbols = (symbols) => [
73
+ ...new Set(
74
+ symbols.map(
75
+ (symbol) => String(symbol || "").trim().toUpperCase()
76
+ ).filter(Boolean)
77
+ )
78
+ ];
79
+ var resolveDerivativesContextBackfillSymbols = (_requestedSymbols = []) => [...import_constants.DERIVATIVES_CONTEXT_REFERENCE_SYMBOLS];
80
+ var chunkArray = (items, size) => {
81
+ const chunks = [];
82
+ for (let i = 0; i < items.length; i += size) {
83
+ chunks.push(items.slice(i, i + size));
84
+ }
85
+ return chunks;
86
+ };
87
+ var isSignalsDerivativesContextEnabled = () => isDerivativesContextBackfillEnabled("CRON");
88
+ var isDerivativesContextBackfillEnabled = (env) => {
89
+ const normalized = String(process.env.DERIVATIVES_CONTEXT_ENABLED ?? "").trim().toLowerCase();
90
+ if (!normalized) return false;
91
+ if (["1", "true", "yes", "on"].includes(normalized)) {
92
+ return true;
93
+ }
94
+ if (normalized === "backtest") {
95
+ return env === "BACKTEST";
96
+ }
97
+ if (normalized === "live") {
98
+ return env !== "BACKTEST";
99
+ }
100
+ return false;
101
+ };
102
+ var shouldBackfillDerivativesContextForSignals = (params) => !params.cacheOnly && isSignalsDerivativesContextEnabled();
103
+ var resolveDerivativesContextIntervals = () => {
104
+ const intervals = (0, import_indicators.normalizeDerivativesIntervals)(
105
+ process.env.DERIVATIVES_CONTEXT_INTERVALS
106
+ );
107
+ return intervals.length ? intervals : DEFAULT_INTERVALS;
108
+ };
109
+ var resolveDerivativesContextLookbackMs = () => {
110
+ const hours = Number(process.env.DERIVATIVES_CONTEXT_LOOKBACK_HOURS);
111
+ const normalizedHours = Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_LOOKBACK_HOURS;
112
+ return normalizedHours * HOUR_MS;
113
+ };
114
+ var resolveDerivativesContextBackfillWindow = (params) => {
115
+ const { startMs, endMs, preloadStartMs, nowMs = Date.now() } = params;
116
+ const safeEndMs = Math.min(endMs, nowMs);
117
+ const safeStartMs = Math.max(0, Math.min(startMs, safeEndMs));
118
+ const lookbackFromMs = safeStartMs - resolveDerivativesContextLookbackMs();
119
+ const requestedFromMs = preloadStartMs != null ? Math.max(Number(preloadStartMs), lookbackFromMs) : lookbackFromMs;
120
+ const fromMs = Math.max(
121
+ 0,
122
+ Math.min(
123
+ Number.isFinite(requestedFromMs) ? requestedFromMs : safeStartMs,
124
+ safeStartMs
125
+ )
126
+ );
127
+ return {
128
+ fromMs: Math.trunc(fromMs),
129
+ toMs: Math.trunc(safeEndMs),
130
+ testStartMs: Math.trunc(safeStartMs)
131
+ };
132
+ };
133
+ var getCoinalyzeApiKey = async (userName) => {
134
+ const settings = await (0, import_userSettings.getUserSettings)(userName);
135
+ return settings.COINALYZE_API_KEY.trim() || String(process.env.COINALYZE_API_KEY ?? "").trim();
136
+ };
137
+ var getCoinalyzeBaseUrl = () => process.env.COINALYZE_BASE_URL?.trim() || "https://api.coinalyze.net/v1";
138
+ var getRequestDelayMs = () => Math.max(
139
+ 100,
140
+ asInt(
141
+ process.env.DERIVATIVES_CONTEXT_BACKFILL_REQUEST_DELAY_MS ?? process.env.COINALYZE_MIN_REQUEST_DELAY_MS,
142
+ 200
143
+ )
144
+ );
145
+ var getRequestTimeoutMs = () => Math.max(5e3, asInt(process.env.COINALYZE_REQUEST_TIMEOUT_MS, 45e3));
146
+ var fetchJsonWithRateLimit = async (url, apiKey) => {
147
+ const requestDelayMs = getRequestDelayMs();
148
+ const requestTimeoutMs = getRequestTimeoutMs();
149
+ const maxRetries = asInt(process.env.COINALYZE_MAX_RETRIES, 4);
150
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
151
+ const waitMs = Math.max(0, lastRequestTs + requestDelayMs - Date.now());
152
+ if (waitMs > 0) {
153
+ await (0, import_async.delay)(waitMs);
154
+ }
155
+ lastRequestTs = Date.now();
156
+ const controller = new AbortController();
157
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
158
+ let response;
159
+ try {
160
+ response = await fetch(url, {
161
+ headers: {
162
+ api_key: apiKey,
163
+ "x-api-key": apiKey,
164
+ Authorization: `Bearer ${apiKey}`
165
+ },
166
+ signal: controller.signal
167
+ });
168
+ } catch (error) {
169
+ clearTimeout(timer);
170
+ const abortError = error instanceof Error && (error.name === "AbortError" || String(error.message).toLowerCase().includes("aborted"));
171
+ if (attempt < maxRetries && abortError) {
172
+ await (0, import_async.delay)(Math.min(12e3, 800 * 2 ** attempt));
173
+ continue;
174
+ }
175
+ throw error;
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ if (response.ok) {
180
+ return response.json();
181
+ }
182
+ const text = await response.text();
183
+ const retryAfterRaw = Number(response.headers.get("retry-after") ?? "");
184
+ const retryAfterMs = Number.isFinite(retryAfterRaw) && retryAfterRaw > 0 ? retryAfterRaw * 1e3 : null;
185
+ const transient = response.status === 429 || response.status >= 500;
186
+ if (attempt < maxRetries && transient) {
187
+ await (0, import_async.delay)(retryAfterMs ?? Math.min(12e3, 800 * 2 ** attempt));
188
+ continue;
189
+ }
190
+ throw new Error(`Coinalyze ${response.status}: ${text}`);
191
+ }
192
+ throw new Error("Coinalyze request failed after retries");
193
+ };
194
+ var fetchCoinalyzeMarkets = async (apiKey) => {
195
+ const raw = await fetchJsonWithRateLimit(
196
+ `${getCoinalyzeBaseUrl()}/future-markets`,
197
+ apiKey
198
+ );
199
+ return Array.isArray(raw) ? raw : [];
200
+ };
201
+ var selectBestMarket = (candidates, exchangePriority) => {
202
+ const scored = candidates.map((item) => {
203
+ let score = 0;
204
+ if (item.is_perpetual) score += 50;
205
+ if (String(item.quote_asset || "").toUpperCase() === "USDT") score += 25;
206
+ if (item.symbol.includes("_PERP")) score += 10;
207
+ const exchange = String(item.exchange || "").toUpperCase();
208
+ const idx = exchangePriority.indexOf(exchange);
209
+ if (idx >= 0) {
210
+ score += (exchangePriority.length - idx) * 10;
211
+ }
212
+ return { item, score };
213
+ });
214
+ scored.sort((a, b) => {
215
+ if (b.score !== a.score) return b.score - a.score;
216
+ return a.item.symbol.localeCompare(b.item.symbol);
217
+ });
218
+ return scored[0]?.item ?? null;
219
+ };
220
+ var buildMatches = (tickers, markets, exchangePriority) => {
221
+ const marketByTicker = /* @__PURE__ */ new Map();
222
+ for (const market of markets) {
223
+ const key = String(market.symbol_on_exchange || "").trim().toUpperCase();
224
+ if (!key) continue;
225
+ const list = marketByTicker.get(key) ?? [];
226
+ list.push(market);
227
+ marketByTicker.set(key, list);
228
+ }
229
+ const matches = [];
230
+ const unmatched = [];
231
+ for (const ticker of tickers) {
232
+ const candidates = marketByTicker.get(ticker) ?? [];
233
+ const selected = selectBestMarket(candidates, exchangePriority);
234
+ if (!selected) {
235
+ unmatched.push(ticker);
236
+ continue;
237
+ }
238
+ matches.push({
239
+ symbol: ticker,
240
+ marketSymbol: selected.symbol,
241
+ exchange: selected.exchange
242
+ });
243
+ }
244
+ return { matches, unmatched };
245
+ };
246
+ var normalizeMetricPoint = (metric, point) => {
247
+ if (metric === "oi") {
248
+ return {
249
+ ...point,
250
+ open_interest: point.open_interest ?? point.openInterest ?? point.oi ?? point.c
251
+ };
252
+ }
253
+ if (metric === "funding") {
254
+ return {
255
+ ...point,
256
+ funding_rate: point.funding_rate ?? point.fundingRate ?? point.rate ?? point.c
257
+ };
258
+ }
259
+ return {
260
+ ...point,
261
+ liq_long: point.liq_long ?? point.long_liq ?? point.long ?? point.l,
262
+ liq_short: point.liq_short ?? point.short_liq ?? point.short ?? point.s
263
+ };
264
+ };
265
+ var toSeriesMap = (raw, metric) => {
266
+ const out = /* @__PURE__ */ new Map();
267
+ if (!Array.isArray(raw)) return out;
268
+ for (const item of raw) {
269
+ const symbol = String(item.symbol || "").trim().toUpperCase();
270
+ const history = Array.isArray(item.history) ? item.history : [];
271
+ if (!symbol) continue;
272
+ out.set(
273
+ symbol,
274
+ history.filter((point) => point && typeof point === "object").map((point) => normalizeMetricPoint(metric, point))
275
+ );
276
+ }
277
+ return out;
278
+ };
279
+ var fetchMetricBatch = async (params) => {
280
+ const { endpoint, metric, marketSymbols, apiKey, interval: interval2, fromMs, toMs } = params;
281
+ const url = new URL(`${getCoinalyzeBaseUrl()}${endpoint}`);
282
+ url.searchParams.set("symbols", marketSymbols.join(","));
283
+ url.searchParams.set("interval", coinalyzeIntervalMap[interval2] || interval2);
284
+ url.searchParams.set("from", String(Math.floor(fromMs / 1e3)));
285
+ url.searchParams.set("to", String(Math.floor(toMs / 1e3)));
286
+ const raw = await fetchJsonWithRateLimit(url.toString(), apiKey);
287
+ return toSeriesMap(raw, metric);
288
+ };
289
+ var skippedBackfillResult = () => ({
290
+ skipped: true,
291
+ rows: 0,
292
+ matchedSymbols: 0,
293
+ unmatchedSymbols: 0,
294
+ failedWindows: 0,
295
+ skippedWindows: 0
296
+ });
297
+ var backfillDerivativesContext = async (params, enabled) => {
298
+ const { userName, startMs, endMs } = params;
299
+ const requestedSymbols = normalizeSymbols(params.symbols);
300
+ const symbols = resolveDerivativesContextBackfillSymbols(requestedSymbols);
301
+ if (!enabled || !requestedSymbols.length || !symbols.length) {
302
+ return skippedBackfillResult();
303
+ }
304
+ const intervals = resolveDerivativesContextIntervals();
305
+ if (!intervals.length) {
306
+ return skippedBackfillResult();
307
+ }
308
+ const apiKey = await getCoinalyzeApiKey(userName);
309
+ if (!apiKey) {
310
+ throw new Error(
311
+ `Missing COINALYZE_API_KEY for derivatives context backfill (user=${userName})`
312
+ );
313
+ }
314
+ const {
315
+ fromMs,
316
+ toMs: safeEndMs,
317
+ testStartMs: safeStartMs
318
+ } = resolveDerivativesContextBackfillWindow({
319
+ startMs,
320
+ endMs,
321
+ preloadStartMs: params.preloadStartMs
322
+ });
323
+ if (safeEndMs <= fromMs) {
324
+ return skippedBackfillResult();
325
+ }
326
+ const exchangePriority = parseList(
327
+ process.env.COINALYZE_EXCHANGE_PRIORITY || process.env.DERIVATIVES_CONTEXT_EXCHANGE_PRIORITY || "A,6,0"
328
+ );
329
+ const markets = await fetchCoinalyzeMarkets(apiKey);
330
+ if (!markets.length) {
331
+ throw new Error("No markets returned by Coinalyze /future-markets");
332
+ }
333
+ const { matches, unmatched } = buildMatches(
334
+ symbols,
335
+ markets,
336
+ exchangePriority
337
+ );
338
+ if (!matches.length) {
339
+ throw new Error(
340
+ "No matched derivatives reference symbols between BTC/ETH and Coinalyze"
341
+ );
342
+ }
343
+ await (0, import_timescale.waitForDbReady)();
344
+ const batchDays = asInt(
345
+ process.env.DERIVATIVES_CONTEXT_BACKFILL_BATCH_DAYS,
346
+ 30
347
+ );
348
+ const symbolBatchSize = asInt(
349
+ process.env.DERIVATIVES_CONTEXT_BACKFILL_SYMBOL_BATCH_SIZE,
350
+ 8
351
+ );
352
+ const symbolBatches = chunkArray(matches, symbolBatchSize);
353
+ const windowsPerPair = Math.ceil((safeEndMs - fromMs) / (batchDays * DAY_MS));
354
+ const totalWindows = symbolBatches.length * intervals.length * windowsPerPair;
355
+ const bar = new import_progress.default(
356
+ ":current/:total [:bar][:percent] :eta(s) :batch rows=:rows fail=:fail skip=:skip",
357
+ {
358
+ total: Math.max(1, totalWindows),
359
+ width: 24
360
+ }
361
+ );
362
+ const oiPath = process.env.COINALYZE_OI_PATH?.trim() || "/open-interest-history";
363
+ const fundingPath = process.env.COINALYZE_FUNDING_PATH?.trim() || "/funding-rate-history";
364
+ const liqPath = process.env.COINALYZE_LIQ_PATH?.trim() || "/liquidation-history";
365
+ let totalRows = 0;
366
+ let failedWindows = 0;
367
+ let skippedWindows = 0;
368
+ console.log(
369
+ import_chalk.default.cyan(
370
+ `derivatives context backfill: referenceSymbols=${matches.length}, requestedSymbols=${requestedSymbols.length}, unmatched=${unmatched.length}, intervals=${intervals.join(",")}, window=${new Date(fromMs).toISOString()}..${new Date(safeEndMs).toISOString()}, testStart=${new Date(safeStartMs).toISOString()}`
371
+ )
372
+ );
373
+ for (const interval2 of intervals) {
374
+ const edgesBySymbol = await (0, import_timescale.getDerivativesDataEdgesForSymbols)(
375
+ matches.map((item) => item.symbol),
376
+ interval2
377
+ );
378
+ for (let batchIdx = 0; batchIdx < symbolBatches.length; batchIdx += 1) {
379
+ const batch = symbolBatches[batchIdx];
380
+ let cursor = fromMs;
381
+ while (cursor < safeEndMs) {
382
+ const toMs = Math.min(safeEndMs, cursor + batchDays * DAY_MS);
383
+ const missingBatch = batch.filter((item) => {
384
+ const edges = edgesBySymbol.get(item.symbol.toUpperCase());
385
+ return edges?.min == null || edges?.max == null || edges.min > cursor || edges.max < toMs;
386
+ });
387
+ try {
388
+ if (!missingBatch.length) {
389
+ skippedWindows += 1;
390
+ } else {
391
+ const marketSymbols = missingBatch.map((item) => item.marketSymbol);
392
+ const oiMap = await fetchMetricBatch({
393
+ endpoint: oiPath,
394
+ metric: "oi",
395
+ marketSymbols,
396
+ apiKey,
397
+ interval: interval2,
398
+ fromMs: cursor,
399
+ toMs
400
+ });
401
+ const fundingMap = await fetchMetricBatch({
402
+ endpoint: fundingPath,
403
+ metric: "funding",
404
+ marketSymbols,
405
+ apiKey,
406
+ interval: interval2,
407
+ fromMs: cursor,
408
+ toMs
409
+ });
410
+ const liqMap = await fetchMetricBatch({
411
+ endpoint: liqPath,
412
+ metric: "liq",
413
+ marketSymbols,
414
+ apiKey,
415
+ interval: interval2,
416
+ fromMs: cursor,
417
+ toMs
418
+ });
419
+ const rows = missingBatch.flatMap((item) => {
420
+ const marketSymbol = item.marketSymbol.toUpperCase();
421
+ const points = (0, import_indicators.mergeCoinalyzeMetrics)({
422
+ symbol: item.symbol,
423
+ oiRaw: oiMap.get(marketSymbol) ?? [],
424
+ fundingRaw: fundingMap.get(marketSymbol) ?? [],
425
+ liqRaw: liqMap.get(marketSymbol) ?? []
426
+ });
427
+ return (0, import_indicators.coinalyzePointsToRows)(points, interval2, "coinalyze");
428
+ });
429
+ if (rows.length) {
430
+ await (0, import_timescale.upsertDerivatives)(rows);
431
+ totalRows += rows.length;
432
+ }
433
+ }
434
+ } catch (error) {
435
+ failedWindows += 1;
436
+ console.error(
437
+ import_chalk.default.red(
438
+ `derivatives context backfill window failed batch=${batchIdx + 1}/${symbolBatches.length} interval=${interval2} ${new Date(cursor).toISOString()}..${new Date(toMs).toISOString()}: ${error}`
439
+ )
440
+ );
441
+ } finally {
442
+ bar.tick(1, {
443
+ batch: import_chalk.default.gray(
444
+ `${batchIdx + 1}/${symbolBatches.length} ${interval2}`
445
+ ),
446
+ rows: totalRows,
447
+ fail: failedWindows,
448
+ skip: skippedWindows
449
+ });
450
+ }
451
+ cursor = toMs + 1;
452
+ }
453
+ }
454
+ }
455
+ console.log("");
456
+ console.log(
457
+ import_chalk.default.green(
458
+ `derivatives context backfill done: rows=${totalRows}, failed_windows=${failedWindows}, skipped_windows=${skippedWindows}`
459
+ )
460
+ );
461
+ if (failedWindows > 0) {
462
+ throw new Error(
463
+ `Derivatives context backfill failed for ${failedWindows} window(s)`
464
+ );
465
+ }
466
+ return {
467
+ skipped: false,
468
+ rows: totalRows,
469
+ matchedSymbols: matches.length,
470
+ unmatchedSymbols: unmatched.length,
471
+ failedWindows,
472
+ skippedWindows
473
+ };
474
+ };
475
+ var backfillDerivativesContextForSignals = async (params) => backfillDerivativesContext(params, isSignalsDerivativesContextEnabled());
476
+
477
+ // src/lib/runtimeSignalsStorage.ts
478
+ var DAY_MS2 = 864e5;
479
+ var RUNTIME_STORAGE_DAY_OFFSET_MS = 5 * 60 * 60 * 1e3;
480
+ var toIsoDayKey = (timestamp) => new Date(timestamp).toISOString().slice(0, 10);
481
+ var toRuntimeStorageDayTimestamp = (timestamp) => Math.floor((timestamp + RUNTIME_STORAGE_DAY_OFFSET_MS) / DAY_MS2) * DAY_MS2;
482
+ var getRuntimeStorageDayKey = (timestamp) => toIsoDayKey(toRuntimeStorageDayTimestamp(timestamp));
483
+ var toRuntimeSignalBucketRef = (signal) => ({
484
+ signalId: signal.signalId,
485
+ symbol: signal.symbol,
486
+ strategy: signal.strategy,
487
+ timestamp: signal.timestamp
488
+ });
489
+ var normalizeRuntimeSignalSkipReason = (reason, fallbackSource) => {
490
+ let source = fallbackSource;
491
+ let normalizedReason = reason;
492
+ if (reason.startsWith("AI_QUALITY_BELOW_MIN")) {
493
+ source = "AI";
494
+ normalizedReason = "MIN_AI_QUALITY";
495
+ } else if (reason === "AI_QUALITY_UNAVAILABLE") {
496
+ source = "AI";
497
+ normalizedReason = "QUALITY_UNAVAILABLE";
498
+ } else if (reason === "ML_RESULT_UNAVAILABLE") {
499
+ source = "ML";
500
+ normalizedReason = "RESULT_UNAVAILABLE";
501
+ } else if (reason.startsWith("ML_THRESHOLD_NOT_MET")) {
502
+ source = "ML";
503
+ normalizedReason = "ML_THRESHOLD";
504
+ } else if (reason.startsWith("HOOK_BEFORE_ENTRY_GATE:")) {
505
+ source = "hook";
506
+ normalizedReason = `BEFORE_ENTRY_GATE:${reason.slice(
507
+ "HOOK_BEFORE_ENTRY_GATE:".length
508
+ )}`;
509
+ } else if (reason === "HOOK_BEFORE_ENTRY_GATE") {
510
+ source = "hook";
511
+ normalizedReason = "BEFORE_ENTRY_GATE";
512
+ } else if (reason === "MAKE_ORDERS_DISABLED" || reason === "ENTRY_POLICY_BLOCKED") {
513
+ source = "policy";
514
+ }
515
+ return {
516
+ source: `skip from ${source}`,
517
+ reason: normalizedReason
518
+ };
519
+ };
520
+ var buildStatsReasonField = (source, reason) => `reason:${source}:${reason}`;
521
+ var buildRuntimeSignalStatsIncrements = (evaluation) => {
522
+ const increments = {
523
+ evaluated: 1
524
+ };
525
+ if (evaluation.status === "signal") {
526
+ increments.signals = 1;
527
+ }
528
+ let skipReason = null;
529
+ let fallbackSource = "core";
530
+ if (evaluation.status === "skip") {
531
+ skipReason = evaluation.reason || "NO_SIGNAL";
532
+ fallbackSource = "core";
533
+ } else if (evaluation.status === "signal" && (evaluation.orderStatus === "skipped" || evaluation.orderStatus === "canceled")) {
534
+ skipReason = evaluation.orderSkipReason || evaluation.reason || `orderStatus=${evaluation.orderStatus}`;
535
+ fallbackSource = "runtime";
536
+ } else if (evaluation.status === "error") {
537
+ skipReason = evaluation.reason || "EVALUATION_ERROR";
538
+ fallbackSource = "runtime";
539
+ }
540
+ if (skipReason) {
541
+ const normalized = normalizeRuntimeSignalSkipReason(
542
+ skipReason,
543
+ fallbackSource
544
+ );
545
+ increments[buildStatsReasonField(normalized.source, normalized.reason)] = 1;
546
+ }
547
+ return increments;
548
+ };
549
+ var shouldStoreDetailedRuntimeSignalEvaluation = (evaluation) => evaluation.status !== "skip";
550
+
551
+ // src/scripts/signals.ts
49
552
  import_args.default.option(["t", "tickers"], "Selected tickers");
50
553
  import_args.default.option(["e", "exclude"], "Exclude tickers from tests");
51
554
  import_args.default.option(["l", "tickersLimit"], "Tickers limit");
@@ -68,11 +571,50 @@ import_args.default.option(
68
571
  "Connector provider or name for signals (e.g. bybit, binance, coinbase, custom)",
69
572
  "bybit"
70
573
  );
71
- var PRELOAD_START = (0, import_time.getTimestamp)(import_constants.SIGNALS_CLI_PRELOAD_DAYS);
574
+ var PRELOAD_START = (0, import_time.getTimestamp)(import_constants2.SIGNALS_CLI_PRELOAD_DAYS);
72
575
  var projectRoot = String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
73
576
  var flags = import_args.default.parse(process.argv);
74
577
  var interval = flags.timeframe.toString();
75
578
  var formatElapsed = (startedAt) => `${((Date.now() - startedAt) / 1e3).toFixed(1)}s`;
579
+ var formatDuration = (startedAt) => {
580
+ const seconds = (Date.now() - startedAt) / 1e3;
581
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
582
+ const minutes = Math.floor(seconds / 60);
583
+ const restSeconds = Math.round(seconds % 60);
584
+ return `${minutes}m ${restSeconds}s`;
585
+ };
586
+ var timeOperation = async (label, operation) => {
587
+ const startedAt = Date.now();
588
+ try {
589
+ return await operation();
590
+ } finally {
591
+ import_logger.logger.info(import_chalk2.default.gray(`${label}: done in ${formatDuration(startedAt)}`));
592
+ }
593
+ };
594
+ var normalizeHookList = (value) => {
595
+ if (Array.isArray(value)) {
596
+ return value;
597
+ }
598
+ return value ? [value] : [];
599
+ };
600
+ var invokeBeforeSignalsHooks = async (hooks, params) => {
601
+ for (const hook of normalizeHookList(
602
+ hooks?.beforeSignals
603
+ )) {
604
+ const result = await hook(params);
605
+ if (result?.abort === true) {
606
+ return result;
607
+ }
608
+ }
609
+ return void 0;
610
+ };
611
+ var invokeAfterSignalsHooks = async (hooks, params) => {
612
+ for (const hook of normalizeHookList(
613
+ hooks?.afterSignals
614
+ )) {
615
+ await hook(params);
616
+ }
617
+ };
76
618
  var resolveStrategyNameByConfigKey = (userName, key) => {
77
619
  const parts = key.split(":");
78
620
  if (parts.length !== 5) {
@@ -121,15 +663,56 @@ var createStrategySkipStats = (runtimeStrategies) => new Map(
121
663
  }
122
664
  ])
123
665
  );
124
- var recordStrategyReason = (strategyStats, strategyName, reason) => {
666
+ var recordStrategyReason = (strategyStats, strategyName, reason, fallbackSource = "core") => {
125
667
  const stats = strategyStats.get(strategyName);
126
668
  if (!stats) {
127
669
  return;
128
670
  }
129
- stats.reasons.set(reason, (stats.reasons.get(reason) ?? 0) + 1);
671
+ const normalized = normalizeRuntimeSignalSkipReason(reason, fallbackSource);
672
+ const normalizedReason = `${normalized.source} / ${normalized.reason}`;
673
+ stats.reasons.set(
674
+ normalizedReason,
675
+ (stats.reasons.get(normalizedReason) ?? 0) + 1
676
+ );
677
+ };
678
+ var buildRuntimeSignalEvaluationId = ({
679
+ strategyName,
680
+ symbol,
681
+ timestamp
682
+ }) => `${strategyName}:${symbol}:${timestamp}`;
683
+ var getTelegramDeliverableSignals = (signals2) => signals2.filter(
684
+ (signal) => signal.orderStatus !== "skipped" && signal.orderStatus !== "canceled"
685
+ );
686
+ var saveRuntimeSignalEvaluation = async (evaluation) => {
687
+ const dayKey = getRuntimeStorageDayKey(evaluation.timestamp);
688
+ if (shouldStoreDetailedRuntimeSignalEvaluation(evaluation)) {
689
+ await (0, import_redis.setHashJsonField)(
690
+ import_redis.redisKeys.runtimeSignalEvaluationBucket(
691
+ evaluation.userName,
692
+ dayKey,
693
+ evaluation.strategy
694
+ ),
695
+ evaluation.evaluationId,
696
+ evaluation,
697
+ {
698
+ expire: import_constants2.TTL_10D
699
+ }
700
+ );
701
+ }
702
+ await (0, import_redis.incrHashFields)(
703
+ import_redis.redisKeys.runtimeSignalEvaluationStatsBucket(
704
+ evaluation.userName,
705
+ dayKey,
706
+ evaluation.strategy
707
+ ),
708
+ buildRuntimeSignalStatsIncrements(evaluation),
709
+ {
710
+ expire: import_constants2.TTL_10D
711
+ }
712
+ );
130
713
  };
131
714
  var logStrategySkipStats = (runtimeStrategies, strategyStats) => {
132
- import_logger.logger.info(import_chalk.default.yellow("skip stats:"));
715
+ import_logger.logger.info(import_chalk2.default.yellow("skip stats:"));
133
716
  for (const { strategyName } of runtimeStrategies) {
134
717
  const stats = strategyStats.get(strategyName);
135
718
  if (!stats) {
@@ -181,6 +764,8 @@ var findSignals = async (symbol, connector, btcBinanceData, btcCoinbaseData, run
181
764
  interval
182
765
  })
183
766
  ]);
767
+ cachedData.pop();
768
+ btcCachedData.pop();
184
769
  const lastCandle = cachedData.pop();
185
770
  const btcLastCandle = btcCachedData.pop();
186
771
  if (!lastCandle || !btcLastCandle) {
@@ -209,20 +794,74 @@ var findSignals = async (symbol, connector, btcBinanceData, btcCoinbaseData, run
209
794
  });
210
795
  const signal = await strategy(lastCandle, btcLastCandle);
211
796
  if (!signal || typeof signal === "string") {
797
+ const reason = typeof signal === "string" && signal.trim() ? signal : "NO_SIGNAL";
212
798
  if (typeof signal === "string") {
213
- recordStrategyReason(strategyStats, strategyName, signal);
799
+ recordStrategyReason(strategyStats, strategyName, signal, "core");
214
800
  }
801
+ await saveRuntimeSignalEvaluation({
802
+ evaluationId: buildRuntimeSignalEvaluationId({
803
+ strategyName,
804
+ symbol,
805
+ timestamp: lastCandle.timestamp
806
+ }),
807
+ userName: flags.user,
808
+ strategy: strategyName,
809
+ symbol,
810
+ interval,
811
+ timestamp: lastCandle.timestamp,
812
+ evaluatedAt: Date.now(),
813
+ status: "skip",
814
+ reason
815
+ });
215
816
  continue;
216
817
  }
217
818
  if (stats) {
218
819
  stats.signals += 1;
219
820
  }
821
+ if (signal.orderStatus === "skipped" && typeof signal.orderSkipReason === "string" && signal.orderSkipReason.trim()) {
822
+ recordStrategyReason(
823
+ strategyStats,
824
+ strategyName,
825
+ signal.orderSkipReason,
826
+ "runtime"
827
+ );
828
+ }
220
829
  strategySignals.push(signal);
221
- await (0, import_redis.setData)(import_redis.redisKeys.signal(symbol, signal.signalId), signal, {
222
- expire: import_constants.TTL_1D
223
- });
224
830
  await (0, import_redis.setData)(import_redis.redisKeys.storeSignal(symbol, signal.signalId), signal, {
225
- expire: import_constants.TTL_3M
831
+ expire: import_constants2.TTL_10D
832
+ });
833
+ await (0, import_redis.setHashJsonField)(
834
+ import_redis.redisKeys.runtimeSignalBucket(
835
+ flags.user,
836
+ getRuntimeStorageDayKey(signal.timestamp),
837
+ signal.strategy
838
+ ),
839
+ signal.signalId,
840
+ toRuntimeSignalBucketRef(signal),
841
+ {
842
+ expire: import_constants2.TTL_10D
843
+ }
844
+ );
845
+ await saveRuntimeSignalEvaluation({
846
+ evaluationId: buildRuntimeSignalEvaluationId({
847
+ strategyName,
848
+ symbol,
849
+ timestamp: lastCandle.timestamp
850
+ }),
851
+ userName: flags.user,
852
+ strategy: strategyName,
853
+ symbol,
854
+ interval,
855
+ timestamp: lastCandle.timestamp,
856
+ evaluatedAt: Date.now(),
857
+ status: "signal",
858
+ reason: signal.orderSkipReason || signal.orderStatus || "SIGNAL",
859
+ signalId: signal.signalId,
860
+ direction: signal.direction,
861
+ orderStatus: signal.orderStatus,
862
+ orderSkipReason: signal.orderSkipReason,
863
+ ...signal.aiAnalysis ? { aiAnalysis: signal.aiAnalysis } : {},
864
+ ...signal.ml ? { ml: signal.ml } : {}
226
865
  });
227
866
  import_logger.logger.info("Signal found %s by strategy %s", symbol, strategyName);
228
867
  }
@@ -232,6 +871,8 @@ var signals = async () => {
232
871
  const startedAt = Date.now();
233
872
  const signals2 = new Array();
234
873
  let status = "completed";
874
+ let projectHooks;
875
+ let afterSignalsHookContext = null;
235
876
  try {
236
877
  const connectorName = await resolveSignalsConnectorName(flags.connector);
237
878
  const connectorFactory = await (0, import_connectors2.getConnectorCreatorByName)(
@@ -276,61 +917,91 @@ var signals = async () => {
276
917
  );
277
918
  }
278
919
  }
279
- const tickers = await (0, import_cli.getTickers)(
280
- marketConnector,
281
- flags.tickers,
282
- flags.exclude,
283
- flags.tickersLimit,
284
- flags.chunk
920
+ const tickers = await timeOperation(
921
+ "tickers load",
922
+ () => (0, import_cli.getTickers)(
923
+ marketConnector,
924
+ flags.tickers,
925
+ flags.exclude,
926
+ flags.tickersLimit,
927
+ flags.chunk
928
+ )
285
929
  );
286
930
  if (flags.showTickersList) {
287
- console.log(import_chalk.default.gray(JSON.stringify(tickers.sort(), null, 2)));
931
+ console.log(import_chalk2.default.gray(JSON.stringify(tickers.sort(), null, 2)));
288
932
  return;
289
933
  }
934
+ const projectConfig = await (0, import_cli.loadTradejsConfig)(projectRoot);
935
+ projectHooks = projectConfig.hooks;
290
936
  if (!flags.cacheOnly) {
291
- await (0, import_cli.update)(
292
- marketConnector,
293
- interval,
294
- tickers,
295
- import_constants.SIGNALS_CLI_PRELOAD_DAYS
937
+ await timeOperation(
938
+ `update ${connectorName}`,
939
+ () => (0, import_cli.update)(marketConnector, interval, tickers, import_constants2.SIGNALS_CLI_PRELOAD_DAYS, {
940
+ connectorLabel: connectorName
941
+ })
296
942
  );
297
943
  if (btcBinanceConnector !== marketConnector) {
298
- await (0, import_cli.update)(
299
- btcBinanceConnector,
300
- interval,
301
- ["BTCUSDT"],
302
- import_constants.SIGNALS_CLI_PRELOAD_DAYS
944
+ await timeOperation(
945
+ `update ${import_connectors.ConnectorNames.Binance}`,
946
+ () => (0, import_cli.update)(
947
+ btcBinanceConnector,
948
+ interval,
949
+ ["BTCUSDT"],
950
+ import_constants2.SIGNALS_CLI_PRELOAD_DAYS,
951
+ { connectorLabel: import_connectors.ConnectorNames.Binance }
952
+ )
303
953
  );
304
954
  }
305
955
  if (btcCoinbaseConnector !== marketConnector) {
306
- await (0, import_cli.update)(
307
- btcCoinbaseConnector,
308
- interval,
309
- ["BTCUSDT"],
310
- import_constants.SIGNALS_CLI_PRELOAD_DAYS
956
+ await timeOperation(
957
+ `update ${import_connectors.ConnectorNames.Coinbase}`,
958
+ () => (0, import_cli.update)(
959
+ btcCoinbaseConnector,
960
+ interval,
961
+ ["BTCUSDT"],
962
+ import_constants2.SIGNALS_CLI_PRELOAD_DAYS,
963
+ { connectorLabel: import_connectors.ConnectorNames.Coinbase }
964
+ )
311
965
  );
312
966
  }
313
967
  }
314
968
  const currentTimestamp = (0, import_time.getTimestamp)();
315
- const [btcBinanceData, btcCoinbaseData] = await Promise.all([
316
- btcBinanceConnector.kline({
317
- symbol: "BTCUSDT",
318
- start: PRELOAD_START,
319
- end: currentTimestamp,
320
- cacheOnly: true,
321
- interval
322
- }),
323
- btcCoinbaseConnector.kline({
324
- symbol: "BTCUSDT",
325
- start: PRELOAD_START,
326
- end: currentTimestamp,
327
- cacheOnly: true,
328
- interval
329
- })
330
- ]);
969
+ if (shouldBackfillDerivativesContextForSignals({
970
+ cacheOnly: Boolean(flags.cacheOnly)
971
+ })) {
972
+ await timeOperation(
973
+ "derivatives context backfill",
974
+ () => backfillDerivativesContextForSignals({
975
+ userName: flags.user,
976
+ symbols: tickers,
977
+ startMs: currentTimestamp,
978
+ endMs: currentTimestamp,
979
+ preloadStartMs: PRELOAD_START
980
+ })
981
+ );
982
+ }
331
983
  if (flags.updateOnly) {
332
984
  return;
333
985
  }
986
+ const [btcBinanceData, btcCoinbaseData] = await timeOperation(
987
+ "reference candles load",
988
+ () => Promise.all([
989
+ btcBinanceConnector.kline({
990
+ symbol: "BTCUSDT",
991
+ start: PRELOAD_START,
992
+ end: currentTimestamp,
993
+ cacheOnly: true,
994
+ interval
995
+ }),
996
+ btcCoinbaseConnector.kline({
997
+ symbol: "BTCUSDT",
998
+ start: PRELOAD_START,
999
+ end: currentTimestamp,
1000
+ cacheOnly: true,
1001
+ interval
1002
+ })
1003
+ ])
1004
+ );
334
1005
  const runtimeStrategies = await loadRuntimeStrategies(flags.user);
335
1006
  if (!runtimeStrategies.length) {
336
1007
  import_logger.logger.warn(
@@ -340,20 +1011,44 @@ var signals = async () => {
340
1011
  return;
341
1012
  }
342
1013
  import_logger.logger.info(
343
- import_chalk.default.yellow(
344
- `loaded strategies: ${runtimeStrategies.map((s) => s.strategyName).join(", ")}`
1014
+ import_chalk2.default.yellow(
1015
+ `loaded strategies (user=${flags.user}): ${runtimeStrategies.map((s) => s.strategyName).join(", ")}`
1016
+ )
1017
+ );
1018
+ afterSignalsHookContext = {
1019
+ connector: marketConnector,
1020
+ connectorName,
1021
+ userName: flags.user,
1022
+ interval,
1023
+ tickers: [...tickers],
1024
+ runtimeStrategies: runtimeStrategies.map(
1025
+ ({ strategyName, strategyConfig }) => ({
1026
+ strategyName,
1027
+ strategyConfig
1028
+ })
345
1029
  )
1030
+ };
1031
+ const beforeSignalsResult = await invokeBeforeSignalsHooks(
1032
+ projectHooks,
1033
+ afterSignalsHookContext
346
1034
  );
1035
+ if (beforeSignalsResult?.abort === true) {
1036
+ import_logger.logger.info(
1037
+ "signals aborted before ticker evaluation: %s",
1038
+ beforeSignalsResult.reason ?? "PROJECT_BEFORE_SIGNALS_ABORTED"
1039
+ );
1040
+ return;
1041
+ }
347
1042
  const strategyStats = createStrategySkipStats(runtimeStrategies);
348
- const bar = new import_progress.default(
1043
+ const bar = new import_progress2.default(
349
1044
  ":current/:total [:bar][:percent] :found :eta(s) :symbol",
350
1045
  {
351
1046
  total: tickers.length,
352
1047
  width: 30
353
1048
  }
354
1049
  );
355
- import_logger.logger.info(import_chalk.default.yellow(`tickers: ${tickers.length}`));
356
- await (0, import_async.runWithConcurrency)(tickers, 5, async (symbol) => {
1050
+ import_logger.logger.info(import_chalk2.default.yellow(`tickers: ${tickers.length}`));
1051
+ await (0, import_async2.runWithConcurrency)(tickers, 5, async (symbol) => {
357
1052
  const strategySignals = await findSignals(
358
1053
  symbol,
359
1054
  marketConnector,
@@ -366,15 +1061,16 @@ var signals = async () => {
366
1061
  signals2.push(...strategySignals);
367
1062
  }
368
1063
  bar.tick(1, {
369
- found: import_chalk.default.cyan(signals2.length),
370
- symbol: import_chalk.default.gray(symbol)
1064
+ found: import_chalk2.default.cyan(signals2.length),
1065
+ symbol: import_chalk2.default.gray(symbol)
371
1066
  });
372
1067
  });
373
- if (!flags.skipScreenshots) {
374
- await (0, import_cli.makeScreenshots)(signals2, "15", flags.user);
375
- }
376
1068
  if (flags.notify) {
377
- await (0, import_cli.sendToTG)(signals2, "15", flags.user);
1069
+ const telegramSignals = getTelegramDeliverableSignals(signals2);
1070
+ if (!flags.skipScreenshots && telegramSignals.length > 0) {
1071
+ await (0, import_cli.makeScreenshots)(telegramSignals, "15", flags.user);
1072
+ }
1073
+ await (0, import_cli.sendToTG)(telegramSignals, "15", flags.user);
378
1074
  }
379
1075
  import_logger.logger.info(
380
1076
  JSON.stringify(
@@ -394,8 +1090,23 @@ var signals = async () => {
394
1090
  );
395
1091
  throw error;
396
1092
  } finally {
1093
+ if (projectHooks && afterSignalsHookContext) {
1094
+ try {
1095
+ await invokeAfterSignalsHooks(projectHooks, {
1096
+ ...afterSignalsHookContext,
1097
+ signals: [...signals2],
1098
+ status,
1099
+ durationMs: Date.now() - startedAt
1100
+ });
1101
+ } catch (error) {
1102
+ import_logger.logger.error(
1103
+ "afterSignals hook failed: %s",
1104
+ error?.message || String(error)
1105
+ );
1106
+ }
1107
+ }
397
1108
  import_logger.logger.info(
398
- import_chalk.default.yellow(
1109
+ import_chalk2.default.yellow(
399
1110
  `signals ${status} in ${formatElapsed(startedAt)} (found=${signals2.length})`
400
1111
  )
401
1112
  );