@tradejs/cli 1.0.6 → 1.0.8

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,446 @@ __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/scripts/signals.ts
49
478
  import_args.default.option(["t", "tickers"], "Selected tickers");
50
479
  import_args.default.option(["e", "exclude"], "Exclude tickers from tests");
51
480
  import_args.default.option(["l", "tickersLimit"], "Tickers limit");
@@ -68,11 +497,50 @@ import_args.default.option(
68
497
  "Connector provider or name for signals (e.g. bybit, binance, coinbase, custom)",
69
498
  "bybit"
70
499
  );
71
- var PRELOAD_START = (0, import_time.getTimestamp)(import_constants.SIGNALS_CLI_PRELOAD_DAYS);
500
+ var PRELOAD_START = (0, import_time.getTimestamp)(import_constants2.SIGNALS_CLI_PRELOAD_DAYS);
72
501
  var projectRoot = String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
73
502
  var flags = import_args.default.parse(process.argv);
74
503
  var interval = flags.timeframe.toString();
75
504
  var formatElapsed = (startedAt) => `${((Date.now() - startedAt) / 1e3).toFixed(1)}s`;
505
+ var formatDuration = (startedAt) => {
506
+ const seconds = (Date.now() - startedAt) / 1e3;
507
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
508
+ const minutes = Math.floor(seconds / 60);
509
+ const restSeconds = Math.round(seconds % 60);
510
+ return `${minutes}m ${restSeconds}s`;
511
+ };
512
+ var timeOperation = async (label, operation) => {
513
+ const startedAt = Date.now();
514
+ try {
515
+ return await operation();
516
+ } finally {
517
+ import_logger.logger.info(import_chalk2.default.gray(`${label}: done in ${formatDuration(startedAt)}`));
518
+ }
519
+ };
520
+ var normalizeHookList = (value) => {
521
+ if (Array.isArray(value)) {
522
+ return value;
523
+ }
524
+ return value ? [value] : [];
525
+ };
526
+ var invokeBeforeSignalsHooks = async (hooks, params) => {
527
+ for (const hook of normalizeHookList(
528
+ hooks?.beforeSignals
529
+ )) {
530
+ const result = await hook(params);
531
+ if (result?.abort === true) {
532
+ return result;
533
+ }
534
+ }
535
+ return void 0;
536
+ };
537
+ var invokeAfterSignalsHooks = async (hooks, params) => {
538
+ for (const hook of normalizeHookList(
539
+ hooks?.afterSignals
540
+ )) {
541
+ await hook(params);
542
+ }
543
+ };
76
544
  var resolveStrategyNameByConfigKey = (userName, key) => {
77
545
  const parts = key.split(":");
78
546
  if (parts.length !== 5) {
@@ -121,15 +589,77 @@ var createStrategySkipStats = (runtimeStrategies) => new Map(
121
589
  }
122
590
  ])
123
591
  );
124
- var recordStrategyReason = (strategyStats, strategyName, reason) => {
592
+ var normalizeSkipStatsReason = (reason, fallbackSource) => {
593
+ let source = fallbackSource;
594
+ let normalizedReason = reason;
595
+ if (reason.startsWith("AI_QUALITY_BELOW_MIN")) {
596
+ source = "AI";
597
+ normalizedReason = "MIN_AI_QUALITY";
598
+ } else if (reason === "AI_QUALITY_UNAVAILABLE") {
599
+ source = "AI";
600
+ normalizedReason = "QUALITY_UNAVAILABLE";
601
+ } else if (reason === "ML_RESULT_UNAVAILABLE") {
602
+ source = "ML";
603
+ normalizedReason = "RESULT_UNAVAILABLE";
604
+ } else if (reason.startsWith("ML_THRESHOLD_NOT_MET")) {
605
+ source = "ML";
606
+ normalizedReason = "ML_THRESHOLD";
607
+ } else if (reason.startsWith("HOOK_BEFORE_ENTRY_GATE:")) {
608
+ source = "hook";
609
+ normalizedReason = `BEFORE_ENTRY_GATE:${reason.slice(
610
+ "HOOK_BEFORE_ENTRY_GATE:".length
611
+ )}`;
612
+ } else if (reason === "HOOK_BEFORE_ENTRY_GATE") {
613
+ source = "hook";
614
+ normalizedReason = "BEFORE_ENTRY_GATE";
615
+ } else if (reason === "MAKE_ORDERS_DISABLED" || reason === "ENTRY_POLICY_BLOCKED") {
616
+ source = "policy";
617
+ }
618
+ return `skip from ${source} / ${normalizedReason}`;
619
+ };
620
+ var recordStrategyReason = (strategyStats, strategyName, reason, fallbackSource = "core") => {
125
621
  const stats = strategyStats.get(strategyName);
126
622
  if (!stats) {
127
623
  return;
128
624
  }
129
- stats.reasons.set(reason, (stats.reasons.get(reason) ?? 0) + 1);
625
+ const normalizedReason = normalizeSkipStatsReason(reason, fallbackSource);
626
+ stats.reasons.set(
627
+ normalizedReason,
628
+ (stats.reasons.get(normalizedReason) ?? 0) + 1
629
+ );
630
+ };
631
+ var buildRuntimeSignalEvaluationId = ({
632
+ strategyName,
633
+ symbol,
634
+ timestamp
635
+ }) => `${strategyName}:${symbol}:${timestamp}`;
636
+ var hasMeaningfulEvaluationReason = (value) => {
637
+ const reason = typeof value === "string" ? value.trim() : "";
638
+ return reason.length > 0 && reason !== "NO_SIGNAL";
639
+ };
640
+ var resolveRuntimeSignalEvaluationTtl = (evaluation) => {
641
+ if (evaluation.status === "signal" || evaluation.status === "error") {
642
+ return import_constants2.TTL_1M;
643
+ }
644
+ if (hasMeaningfulEvaluationReason(evaluation.reason) || hasMeaningfulEvaluationReason(evaluation.orderSkipReason)) {
645
+ return import_constants2.TTL_1M;
646
+ }
647
+ return import_constants2.TTL_3D;
648
+ };
649
+ var saveRuntimeSignalEvaluation = async (evaluation) => {
650
+ await (0, import_redis.setData)(
651
+ import_redis.redisKeys.runtimeSignalEvaluation(
652
+ evaluation.userName,
653
+ evaluation.evaluationId
654
+ ),
655
+ evaluation,
656
+ {
657
+ expire: resolveRuntimeSignalEvaluationTtl(evaluation)
658
+ }
659
+ );
130
660
  };
131
661
  var logStrategySkipStats = (runtimeStrategies, strategyStats) => {
132
- import_logger.logger.info(import_chalk.default.yellow("skip stats:"));
662
+ import_logger.logger.info(import_chalk2.default.yellow("skip stats:"));
133
663
  for (const { strategyName } of runtimeStrategies) {
134
664
  const stats = strategyStats.get(strategyName);
135
665
  if (!stats) {
@@ -209,20 +739,72 @@ var findSignals = async (symbol, connector, btcBinanceData, btcCoinbaseData, run
209
739
  });
210
740
  const signal = await strategy(lastCandle, btcLastCandle);
211
741
  if (!signal || typeof signal === "string") {
742
+ const reason = typeof signal === "string" && signal.trim() ? signal : "NO_SIGNAL";
212
743
  if (typeof signal === "string") {
213
- recordStrategyReason(strategyStats, strategyName, signal);
744
+ recordStrategyReason(strategyStats, strategyName, signal, "core");
214
745
  }
746
+ await saveRuntimeSignalEvaluation({
747
+ evaluationId: buildRuntimeSignalEvaluationId({
748
+ strategyName,
749
+ symbol,
750
+ timestamp: lastCandle.timestamp
751
+ }),
752
+ userName: flags.user,
753
+ strategy: strategyName,
754
+ symbol,
755
+ interval,
756
+ timestamp: lastCandle.timestamp,
757
+ evaluatedAt: Date.now(),
758
+ status: "skip",
759
+ reason
760
+ });
215
761
  continue;
216
762
  }
217
763
  if (stats) {
218
764
  stats.signals += 1;
219
765
  }
766
+ if (signal.orderStatus === "skipped" && typeof signal.orderSkipReason === "string" && signal.orderSkipReason.trim()) {
767
+ recordStrategyReason(
768
+ strategyStats,
769
+ strategyName,
770
+ signal.orderSkipReason,
771
+ "runtime"
772
+ );
773
+ }
220
774
  strategySignals.push(signal);
221
775
  await (0, import_redis.setData)(import_redis.redisKeys.signal(symbol, signal.signalId), signal, {
222
- expire: import_constants.TTL_1D
776
+ expire: import_constants2.TTL_1D
223
777
  });
224
778
  await (0, import_redis.setData)(import_redis.redisKeys.storeSignal(symbol, signal.signalId), signal, {
225
- expire: import_constants.TTL_3M
779
+ expire: import_constants2.TTL_3M
780
+ });
781
+ await (0, import_redis.setData)(
782
+ import_redis.redisKeys.runtimeSignal(flags.user, signal.signalId),
783
+ signal,
784
+ {
785
+ expire: import_constants2.TTL_1M
786
+ }
787
+ );
788
+ await saveRuntimeSignalEvaluation({
789
+ evaluationId: buildRuntimeSignalEvaluationId({
790
+ strategyName,
791
+ symbol,
792
+ timestamp: lastCandle.timestamp
793
+ }),
794
+ userName: flags.user,
795
+ strategy: strategyName,
796
+ symbol,
797
+ interval,
798
+ timestamp: lastCandle.timestamp,
799
+ evaluatedAt: Date.now(),
800
+ status: "signal",
801
+ reason: signal.orderSkipReason || signal.orderStatus || "SIGNAL",
802
+ signalId: signal.signalId,
803
+ direction: signal.direction,
804
+ orderStatus: signal.orderStatus,
805
+ orderSkipReason: signal.orderSkipReason,
806
+ ...signal.aiAnalysis ? { aiAnalysis: signal.aiAnalysis } : {},
807
+ ...signal.ml ? { ml: signal.ml } : {}
226
808
  });
227
809
  import_logger.logger.info("Signal found %s by strategy %s", symbol, strategyName);
228
810
  }
@@ -232,6 +814,8 @@ var signals = async () => {
232
814
  const startedAt = Date.now();
233
815
  const signals2 = new Array();
234
816
  let status = "completed";
817
+ let projectHooks;
818
+ let afterSignalsHookContext = null;
235
819
  try {
236
820
  const connectorName = await resolveSignalsConnectorName(flags.connector);
237
821
  const connectorFactory = await (0, import_connectors2.getConnectorCreatorByName)(
@@ -276,61 +860,91 @@ var signals = async () => {
276
860
  );
277
861
  }
278
862
  }
279
- const tickers = await (0, import_cli.getTickers)(
280
- marketConnector,
281
- flags.tickers,
282
- flags.exclude,
283
- flags.tickersLimit,
284
- flags.chunk
863
+ const tickers = await timeOperation(
864
+ "tickers load",
865
+ () => (0, import_cli.getTickers)(
866
+ marketConnector,
867
+ flags.tickers,
868
+ flags.exclude,
869
+ flags.tickersLimit,
870
+ flags.chunk
871
+ )
285
872
  );
286
873
  if (flags.showTickersList) {
287
- console.log(import_chalk.default.gray(JSON.stringify(tickers.sort(), null, 2)));
874
+ console.log(import_chalk2.default.gray(JSON.stringify(tickers.sort(), null, 2)));
288
875
  return;
289
876
  }
877
+ const projectConfig = await (0, import_cli.loadTradejsConfig)(projectRoot);
878
+ projectHooks = projectConfig.hooks;
290
879
  if (!flags.cacheOnly) {
291
- await (0, import_cli.update)(
292
- marketConnector,
293
- interval,
294
- tickers,
295
- import_constants.SIGNALS_CLI_PRELOAD_DAYS
880
+ await timeOperation(
881
+ `update ${connectorName}`,
882
+ () => (0, import_cli.update)(marketConnector, interval, tickers, import_constants2.SIGNALS_CLI_PRELOAD_DAYS, {
883
+ connectorLabel: connectorName
884
+ })
296
885
  );
297
886
  if (btcBinanceConnector !== marketConnector) {
298
- await (0, import_cli.update)(
299
- btcBinanceConnector,
300
- interval,
301
- ["BTCUSDT"],
302
- import_constants.SIGNALS_CLI_PRELOAD_DAYS
887
+ await timeOperation(
888
+ `update ${import_connectors.ConnectorNames.Binance}`,
889
+ () => (0, import_cli.update)(
890
+ btcBinanceConnector,
891
+ interval,
892
+ ["BTCUSDT"],
893
+ import_constants2.SIGNALS_CLI_PRELOAD_DAYS,
894
+ { connectorLabel: import_connectors.ConnectorNames.Binance }
895
+ )
303
896
  );
304
897
  }
305
898
  if (btcCoinbaseConnector !== marketConnector) {
306
- await (0, import_cli.update)(
307
- btcCoinbaseConnector,
308
- interval,
309
- ["BTCUSDT"],
310
- import_constants.SIGNALS_CLI_PRELOAD_DAYS
899
+ await timeOperation(
900
+ `update ${import_connectors.ConnectorNames.Coinbase}`,
901
+ () => (0, import_cli.update)(
902
+ btcCoinbaseConnector,
903
+ interval,
904
+ ["BTCUSDT"],
905
+ import_constants2.SIGNALS_CLI_PRELOAD_DAYS,
906
+ { connectorLabel: import_connectors.ConnectorNames.Coinbase }
907
+ )
311
908
  );
312
909
  }
313
910
  }
314
911
  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
- ]);
912
+ if (shouldBackfillDerivativesContextForSignals({
913
+ cacheOnly: Boolean(flags.cacheOnly)
914
+ })) {
915
+ await timeOperation(
916
+ "derivatives context backfill",
917
+ () => backfillDerivativesContextForSignals({
918
+ userName: flags.user,
919
+ symbols: tickers,
920
+ startMs: currentTimestamp,
921
+ endMs: currentTimestamp,
922
+ preloadStartMs: PRELOAD_START
923
+ })
924
+ );
925
+ }
331
926
  if (flags.updateOnly) {
332
927
  return;
333
928
  }
929
+ const [btcBinanceData, btcCoinbaseData] = await timeOperation(
930
+ "reference candles load",
931
+ () => Promise.all([
932
+ btcBinanceConnector.kline({
933
+ symbol: "BTCUSDT",
934
+ start: PRELOAD_START,
935
+ end: currentTimestamp,
936
+ cacheOnly: true,
937
+ interval
938
+ }),
939
+ btcCoinbaseConnector.kline({
940
+ symbol: "BTCUSDT",
941
+ start: PRELOAD_START,
942
+ end: currentTimestamp,
943
+ cacheOnly: true,
944
+ interval
945
+ })
946
+ ])
947
+ );
334
948
  const runtimeStrategies = await loadRuntimeStrategies(flags.user);
335
949
  if (!runtimeStrategies.length) {
336
950
  import_logger.logger.warn(
@@ -340,20 +954,44 @@ var signals = async () => {
340
954
  return;
341
955
  }
342
956
  import_logger.logger.info(
343
- import_chalk.default.yellow(
344
- `loaded strategies: ${runtimeStrategies.map((s) => s.strategyName).join(", ")}`
957
+ import_chalk2.default.yellow(
958
+ `loaded strategies (user=${flags.user}): ${runtimeStrategies.map((s) => s.strategyName).join(", ")}`
345
959
  )
346
960
  );
961
+ afterSignalsHookContext = {
962
+ connector: marketConnector,
963
+ connectorName,
964
+ userName: flags.user,
965
+ interval,
966
+ tickers: [...tickers],
967
+ runtimeStrategies: runtimeStrategies.map(
968
+ ({ strategyName, strategyConfig }) => ({
969
+ strategyName,
970
+ strategyConfig
971
+ })
972
+ )
973
+ };
974
+ const beforeSignalsResult = await invokeBeforeSignalsHooks(
975
+ projectHooks,
976
+ afterSignalsHookContext
977
+ );
978
+ if (beforeSignalsResult?.abort === true) {
979
+ import_logger.logger.info(
980
+ "signals aborted before ticker evaluation: %s",
981
+ beforeSignalsResult.reason ?? "PROJECT_BEFORE_SIGNALS_ABORTED"
982
+ );
983
+ return;
984
+ }
347
985
  const strategyStats = createStrategySkipStats(runtimeStrategies);
348
- const bar = new import_progress.default(
986
+ const bar = new import_progress2.default(
349
987
  ":current/:total [:bar][:percent] :found :eta(s) :symbol",
350
988
  {
351
989
  total: tickers.length,
352
990
  width: 30
353
991
  }
354
992
  );
355
- import_logger.logger.info(import_chalk.default.yellow(`tickers: ${tickers.length}`));
356
- await (0, import_async.runWithConcurrency)(tickers, 5, async (symbol) => {
993
+ import_logger.logger.info(import_chalk2.default.yellow(`tickers: ${tickers.length}`));
994
+ await (0, import_async2.runWithConcurrency)(tickers, 5, async (symbol) => {
357
995
  const strategySignals = await findSignals(
358
996
  symbol,
359
997
  marketConnector,
@@ -366,8 +1004,8 @@ var signals = async () => {
366
1004
  signals2.push(...strategySignals);
367
1005
  }
368
1006
  bar.tick(1, {
369
- found: import_chalk.default.cyan(signals2.length),
370
- symbol: import_chalk.default.gray(symbol)
1007
+ found: import_chalk2.default.cyan(signals2.length),
1008
+ symbol: import_chalk2.default.gray(symbol)
371
1009
  });
372
1010
  });
373
1011
  if (!flags.skipScreenshots) {
@@ -394,8 +1032,23 @@ var signals = async () => {
394
1032
  );
395
1033
  throw error;
396
1034
  } finally {
1035
+ if (projectHooks && afterSignalsHookContext) {
1036
+ try {
1037
+ await invokeAfterSignalsHooks(projectHooks, {
1038
+ ...afterSignalsHookContext,
1039
+ signals: [...signals2],
1040
+ status,
1041
+ durationMs: Date.now() - startedAt
1042
+ });
1043
+ } catch (error) {
1044
+ import_logger.logger.error(
1045
+ "afterSignals hook failed: %s",
1046
+ error?.message || String(error)
1047
+ );
1048
+ }
1049
+ }
397
1050
  import_logger.logger.info(
398
- import_chalk.default.yellow(
1051
+ import_chalk2.default.yellow(
399
1052
  `signals ${status} in ${formatElapsed(startedAt)} (found=${signals2.length})`
400
1053
  )
401
1054
  );