@tradejs/app 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.
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { intervalToMs, mergeData } from '@tradejs/core/data';
2
3
  import {
3
4
  createIndicators,
4
5
  getRegisteredIndicatorEntries,
@@ -13,10 +14,14 @@ import {
13
14
  ConnectorCreator,
14
15
  } from '@tradejs/types';
15
16
  import { getCurrentUserName } from '@app/lib/currentUser';
17
+ import { normalizeEndToIntervalBoundary } from '@app/lib/klineWindow';
16
18
 
17
19
  export const dynamic = 'force-dynamic';
18
20
  const projectRoot =
19
21
  String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
22
+ const DEFAULT_KLINE_CACHE_TTL_MS = 30_000;
23
+ const MAX_KLINE_CACHE_ENTRIES = 500;
24
+ const MAX_KLINE_CACHE_BYTES = 32 * 1024 * 1024;
20
25
 
21
26
  interface Params {
22
27
  provider: string;
@@ -24,6 +29,193 @@ interface Params {
24
29
  interval: string;
25
30
  }
26
31
 
32
+ type KlineCacheEntry = {
33
+ expiresAt: number;
34
+ sizeBytes: number;
35
+ value: KlineChartData;
36
+ };
37
+
38
+ type ManagedKlineCache = {
39
+ entries: Map<string, KlineCacheEntry>;
40
+ totalBytes: number;
41
+ };
42
+
43
+ type PluginRegistrySnapshot = {
44
+ pluginKeys: string[];
45
+ signature: string;
46
+ };
47
+
48
+ declare global {
49
+ // eslint-disable-next-line no-var
50
+ var __tradejsKlineRawCache__: ManagedKlineCache | undefined;
51
+ // eslint-disable-next-line no-var
52
+ var __tradejsKlineBtcRawCache__: ManagedKlineCache | undefined;
53
+ // eslint-disable-next-line no-var
54
+ var __tradejsKlineEnrichedCache__: ManagedKlineCache | undefined;
55
+ // eslint-disable-next-line no-var
56
+ var __tradejsKlineInflightRequests__:
57
+ | Map<string, Promise<KlineChartData>>
58
+ | undefined;
59
+ // eslint-disable-next-line no-var
60
+ var __tradejsPluginRegistrySnapshotPromise__:
61
+ | Promise<PluginRegistrySnapshot>
62
+ | undefined;
63
+ }
64
+
65
+ const cloneKlineData = (data: KlineChartData) =>
66
+ data.map((candle) => ({ ...candle })) as KlineChartData;
67
+
68
+ const createManagedCache = (): ManagedKlineCache => ({
69
+ entries: new Map<string, KlineCacheEntry>(),
70
+ totalBytes: 0,
71
+ });
72
+
73
+ const getRawKlineCache = () => {
74
+ if (!global.__tradejsKlineRawCache__) {
75
+ global.__tradejsKlineRawCache__ = createManagedCache();
76
+ }
77
+
78
+ return global.__tradejsKlineRawCache__;
79
+ };
80
+
81
+ const getBtcKlineCache = () => {
82
+ if (!global.__tradejsKlineBtcRawCache__) {
83
+ global.__tradejsKlineBtcRawCache__ = createManagedCache();
84
+ }
85
+
86
+ return global.__tradejsKlineBtcRawCache__;
87
+ };
88
+
89
+ const getEnrichedKlineCache = () => {
90
+ if (!global.__tradejsKlineEnrichedCache__) {
91
+ global.__tradejsKlineEnrichedCache__ = createManagedCache();
92
+ }
93
+
94
+ return global.__tradejsKlineEnrichedCache__;
95
+ };
96
+
97
+ const getInflightRequestMap = () => {
98
+ if (!global.__tradejsKlineInflightRequests__) {
99
+ global.__tradejsKlineInflightRequests__ = new Map<
100
+ string,
101
+ Promise<KlineChartData>
102
+ >();
103
+ }
104
+
105
+ return global.__tradejsKlineInflightRequests__;
106
+ };
107
+
108
+ const measureKlineDataBytes = (value: KlineChartData) =>
109
+ Buffer.byteLength(JSON.stringify(value), 'utf8');
110
+
111
+ const pruneCache = (cache: ManagedKlineCache, now: number) => {
112
+ for (const [key, entry] of cache.entries) {
113
+ if (entry.expiresAt <= now) {
114
+ cache.entries.delete(key);
115
+ cache.totalBytes -= entry.sizeBytes;
116
+ }
117
+ }
118
+
119
+ while (
120
+ cache.entries.size > MAX_KLINE_CACHE_ENTRIES ||
121
+ cache.totalBytes > MAX_KLINE_CACHE_BYTES
122
+ ) {
123
+ const oldestKey = cache.entries.keys().next().value;
124
+ if (!oldestKey) {
125
+ break;
126
+ }
127
+
128
+ const entry = cache.entries.get(oldestKey);
129
+ cache.entries.delete(oldestKey);
130
+ cache.totalBytes -= entry?.sizeBytes ?? 0;
131
+ }
132
+
133
+ cache.totalBytes = Math.max(0, cache.totalBytes);
134
+ };
135
+
136
+ const getCacheTtlMs = (interval: Interval) =>
137
+ Math.min(intervalToMs(interval), DEFAULT_KLINE_CACHE_TTL_MS);
138
+
139
+ const buildRawCacheKey = (params: {
140
+ provider: string;
141
+ symbol: string;
142
+ interval: Interval;
143
+ start: number;
144
+ end: number;
145
+ cacheOnly?: boolean;
146
+ }) =>
147
+ [
148
+ params.provider,
149
+ params.symbol,
150
+ params.interval,
151
+ params.start,
152
+ params.end,
153
+ params.cacheOnly ? 'cache' : 'live',
154
+ ].join(':');
155
+
156
+ const buildEnrichedCacheKey = (params: {
157
+ userName: string;
158
+ provider: string;
159
+ symbol: string;
160
+ interval: Interval;
161
+ start: number;
162
+ end: number;
163
+ historicalEnd: number;
164
+ cacheOnly?: boolean;
165
+ pluginSignature: string;
166
+ }) =>
167
+ [
168
+ params.userName,
169
+ params.provider,
170
+ params.symbol,
171
+ params.interval,
172
+ params.start,
173
+ params.end,
174
+ params.historicalEnd,
175
+ params.cacheOnly ? 'cache' : 'live',
176
+ params.pluginSignature,
177
+ ].join(':');
178
+
179
+ const getCachedKline = (cache: ManagedKlineCache, key: string, now: number) => {
180
+ const cached = cache.entries.get(key);
181
+ if (!cached) {
182
+ return null;
183
+ }
184
+
185
+ if (cached.expiresAt <= now) {
186
+ cache.entries.delete(key);
187
+ cache.totalBytes -= cached.sizeBytes;
188
+ cache.totalBytes = Math.max(0, cache.totalBytes);
189
+ return null;
190
+ }
191
+
192
+ return cloneKlineData(cached.value);
193
+ };
194
+
195
+ const setCachedKline = (
196
+ cache: ManagedKlineCache,
197
+ key: string,
198
+ value: KlineChartData,
199
+ ttlMs: number,
200
+ now: number,
201
+ ) => {
202
+ const nextValue = cloneKlineData(value);
203
+ const nextSizeBytes = measureKlineDataBytes(nextValue);
204
+ const previous = cache.entries.get(key);
205
+
206
+ if (previous) {
207
+ cache.totalBytes -= previous.sizeBytes;
208
+ }
209
+
210
+ cache.entries.set(key, {
211
+ expiresAt: now + ttlMs,
212
+ sizeBytes: nextSizeBytes,
213
+ value: nextValue,
214
+ });
215
+ cache.totalBytes += nextSizeBytes;
216
+ pruneCache(cache, now);
217
+ };
218
+
27
219
  const enrichWithPluginIndicators = (
28
220
  data: KlineChartData,
29
221
  btcData: KlineChartData,
@@ -38,7 +230,7 @@ const enrichWithPluginIndicators = (
38
230
  pluginRegistryScope: projectRoot,
39
231
  }).result() as Record<string, number[]>;
40
232
 
41
- const nextData = data.map((candle) => ({ ...candle }));
233
+ const nextData = cloneKlineData(data);
42
234
 
43
235
  for (const pluginKey of pluginKeys) {
44
236
  const series = history[pluginKey];
@@ -63,6 +255,27 @@ const enrichWithPluginIndicators = (
63
255
  return nextData;
64
256
  };
65
257
 
258
+ const getPluginRegistrySnapshot = async (): Promise<PluginRegistrySnapshot> => {
259
+ if (!global.__tradejsPluginRegistrySnapshotPromise__) {
260
+ global.__tradejsPluginRegistrySnapshotPromise__ = (async () => {
261
+ await ensureIndicatorPluginsLoaded(projectRoot);
262
+ const pluginKeys = getRegisteredIndicatorEntries(projectRoot)
263
+ .map((entry) => entry.historyKey || entry.indicator.id)
264
+ .sort();
265
+
266
+ return {
267
+ pluginKeys,
268
+ signature: pluginKeys.join(','),
269
+ };
270
+ })().catch((error) => {
271
+ global.__tradejsPluginRegistrySnapshotPromise__ = undefined;
272
+ throw error;
273
+ });
274
+ }
275
+
276
+ return global.__tradejsPluginRegistrySnapshotPromise__;
277
+ };
278
+
66
279
  export const POST = async (
67
280
  request: NextRequest,
68
281
  { params }: { params: Promise<Params> },
@@ -86,42 +299,151 @@ export const POST = async (
86
299
  );
87
300
  }
88
301
 
89
- const connectorCreator =
90
- (await getConnectorCreatorByProvider(provider, projectRoot)) ||
91
- (await getConnectorCreatorByProvider('bybit', projectRoot));
92
- if (!connectorCreator) {
93
- throw new Error('No connector available for provider');
94
- }
95
- const connector = await (connectorCreator as ConnectorCreator)({
302
+ const typedInterval = interval as Interval;
303
+ const normalizedEnd = normalizeEndToIntervalBoundary(
304
+ Number(options.end),
305
+ typedInterval,
306
+ );
307
+ const historicalEnd = Math.min(Number(options.end), normalizedEnd);
308
+ const pluginSnapshot = await getPluginRegistrySnapshot();
309
+ const requestKey = buildEnrichedCacheKey({
96
310
  userName,
97
- });
98
-
99
- const baseData = await connector.kline({
311
+ provider,
100
312
  symbol,
101
- interval: interval as Interval,
102
- ...options,
313
+ interval: typedInterval,
314
+ start: Number(options.start ?? 0),
315
+ end: Number(options.end),
316
+ historicalEnd,
317
+ cacheOnly: Boolean(options.cacheOnly),
318
+ pluginSignature: pluginSnapshot.signature,
103
319
  });
320
+ const now = Date.now();
321
+ const ttlMs = getCacheTtlMs(typedInterval);
322
+ const enrichedCache = getEnrichedKlineCache();
323
+ const cachedEnriched = getCachedKline(enrichedCache, requestKey, now);
324
+ if (cachedEnriched) {
325
+ return NextResponse.json({ data: cachedEnriched });
326
+ }
104
327
 
105
- await ensureIndicatorPluginsLoaded(projectRoot);
106
- const pluginKeys = getRegisteredIndicatorEntries(projectRoot).map(
107
- (entry) => entry.historyKey || entry.indicator.id,
108
- );
109
- if (!pluginKeys.length) {
110
- return NextResponse.json({ data: baseData });
328
+ const inflightRequests = getInflightRequestMap();
329
+ const inflight = inflightRequests.get(requestKey);
330
+ if (inflight) {
331
+ return NextResponse.json({ data: await inflight });
111
332
  }
112
333
 
113
- const btcData =
114
- symbol === 'BTCUSDT'
115
- ? baseData
116
- : await connector.kline({
117
- symbol: 'BTCUSDT',
118
- interval: interval as Interval,
334
+ const pending = (async () => {
335
+ const connectorCreator =
336
+ (await getConnectorCreatorByProvider(provider, projectRoot)) ||
337
+ (await getConnectorCreatorByProvider('bybit', projectRoot));
338
+ if (!connectorCreator) {
339
+ throw new Error('No connector available for provider');
340
+ }
341
+ const connector = await (connectorCreator as ConnectorCreator)({
342
+ userName,
343
+ });
344
+
345
+ const liveTailRequired = Number(options.end) > historicalEnd;
346
+ const liveTailStart = Math.max(Number(options.start ?? 0), historicalEnd);
347
+ const rawCache = getRawKlineCache();
348
+ const btcCache = getBtcKlineCache();
349
+
350
+ const fetchRawSegment = async (segmentParams: {
351
+ symbol: string;
352
+ start: number;
353
+ end: number;
354
+ useBtcCache?: boolean;
355
+ }) => {
356
+ if (segmentParams.end <= segmentParams.start) {
357
+ return [] as KlineChartData;
358
+ }
359
+
360
+ const cache = segmentParams.useBtcCache ? btcCache : rawCache;
361
+ const cacheKey = buildRawCacheKey({
362
+ provider,
363
+ symbol: segmentParams.symbol,
364
+ interval: typedInterval,
365
+ start: segmentParams.start,
366
+ end: segmentParams.end,
367
+ cacheOnly: Boolean(options.cacheOnly),
368
+ });
369
+ const cached = getCachedKline(cache, cacheKey, now);
370
+ if (cached) {
371
+ return cached;
372
+ }
373
+
374
+ const data = await connector.kline({
375
+ symbol: segmentParams.symbol,
376
+ interval: typedInterval,
377
+ ...options,
378
+ start: segmentParams.start,
379
+ end: segmentParams.end,
380
+ });
381
+
382
+ setCachedKline(cache, cacheKey, data, ttlMs, now);
383
+ return data;
384
+ };
385
+
386
+ const baseHistorical =
387
+ historicalEnd > Number(options.start ?? 0)
388
+ ? await fetchRawSegment({
389
+ symbol,
390
+ start: Number(options.start ?? 0),
391
+ end: historicalEnd,
392
+ })
393
+ : [];
394
+ const baseLiveTail = liveTailRequired
395
+ ? await connector.kline({
396
+ symbol,
397
+ interval: typedInterval,
119
398
  ...options,
120
- });
399
+ start: liveTailStart,
400
+ end: Number(options.end),
401
+ })
402
+ : [];
403
+ const baseData = mergeData(baseHistorical, baseLiveTail);
404
+
405
+ if (!pluginSnapshot.pluginKeys.length) {
406
+ setCachedKline(enrichedCache, requestKey, baseData, ttlMs, now);
407
+ return baseData;
408
+ }
409
+
410
+ const btcData =
411
+ symbol === 'BTCUSDT'
412
+ ? baseData
413
+ : mergeData(
414
+ historicalEnd > Number(options.start ?? 0)
415
+ ? await fetchRawSegment({
416
+ symbol: 'BTCUSDT',
417
+ start: Number(options.start ?? 0),
418
+ end: historicalEnd,
419
+ useBtcCache: true,
420
+ })
421
+ : [],
422
+ liveTailRequired
423
+ ? await connector.kline({
424
+ symbol: 'BTCUSDT',
425
+ interval: typedInterval,
426
+ ...options,
427
+ start: liveTailStart,
428
+ end: Number(options.end),
429
+ })
430
+ : [],
431
+ );
432
+
433
+ const data = enrichWithPluginIndicators(
434
+ baseData,
435
+ btcData,
436
+ pluginSnapshot.pluginKeys,
437
+ );
438
+ setCachedKline(enrichedCache, requestKey, data, ttlMs, now);
439
+ return data;
440
+ })().finally(() => {
441
+ inflightRequests.delete(requestKey);
442
+ });
121
443
 
122
- const data = enrichWithPluginIndicators(baseData, btcData, pluginKeys);
444
+ inflightRequests.set(requestKey, pending);
123
445
 
124
- return NextResponse.json({ data });
446
+ return NextResponse.json({ data: await pending });
125
447
  } catch (error) {
126
448
  logger.log('error', `Kline fetch error: %o`, error);
127
449
  return NextResponse.json(
@@ -1,8 +1,8 @@
1
- import _ from 'lodash';
2
1
  import { NextResponse } from 'next/server';
3
2
  import { getData, redisKeys } from '@tradejs/infra/redis';
4
3
  import { logger } from '@tradejs/infra/logger';
5
4
  import { Signal } from '@tradejs/types';
5
+ import { getCurrentUserName } from '@app/lib/currentUser';
6
6
 
7
7
  export const dynamic = 'force-dynamic';
8
8
 
@@ -16,6 +16,11 @@ export const GET = async (
16
16
  { params }: { params: Promise<Params> },
17
17
  ) => {
18
18
  try {
19
+ const userName = await getCurrentUserName();
20
+ if (!userName) {
21
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
22
+ }
23
+
19
24
  const { symbol, signalId } = await params;
20
25
 
21
26
  if (!symbol || !signalId) {
@@ -25,11 +30,9 @@ export const GET = async (
25
30
  );
26
31
  }
27
32
 
28
- let signal: Signal = await getData(redisKeys.signal(symbol, signalId));
29
-
30
- if (!signal || _.isEmpty(signal)) {
31
- signal = await getData(redisKeys.storeSignal(symbol, signalId));
32
- }
33
+ const signal: Signal = await getData(
34
+ redisKeys.storeSignal(symbol, signalId),
35
+ );
33
36
 
34
37
  return NextResponse.json({ signal });
35
38
  } catch (error) {
@@ -1,6 +1,10 @@
1
1
  import bcrypt from 'bcryptjs';
2
2
  import { NextResponse } from 'next/server';
3
+ import { normalizeAiResponseLanguage } from '@tradejs/infra/aiLanguages';
4
+ import { normalizeAiEndpoint } from '@tradejs/infra/aiEndpoints';
5
+ import { normalizeAiModel } from '@tradejs/infra/aiModels';
3
6
  import {
7
+ getUserRecord,
4
8
  getUserSettings,
5
9
  updateUserRecord,
6
10
  type UserRecord,
@@ -18,12 +22,6 @@ type UpdateBody =
18
22
  apiSecret?: string;
19
23
  };
20
24
  }
21
- | {
22
- section: 'token';
23
- data?: {
24
- token?: string;
25
- };
26
- }
27
25
  | {
28
26
  section: 'coinalyze';
29
27
  data?: {
@@ -31,10 +29,12 @@ type UpdateBody =
31
29
  };
32
30
  }
33
31
  | {
34
- section: 'openai';
32
+ section: 'ai';
35
33
  data?: {
36
34
  apiKey?: string;
37
35
  apiEndpoint?: string;
36
+ model?: string;
37
+ responseLanguage?: string;
38
38
  };
39
39
  }
40
40
  | {
@@ -80,13 +80,14 @@ const toResponse = (settings: UserSettings) => ({
80
80
  apiKey: maskSecret(settings.BYBIT_API_KEY),
81
81
  apiSecret: maskSecret(settings.BYBIT_API_SECRET),
82
82
  },
83
- token: maskSecret(settings.token),
84
83
  coinalyze: {
85
84
  apiKey: maskSecret(settings.COINALYZE_API_KEY),
86
85
  },
87
- openai: {
88
- apiKey: maskSecret(settings.OPENAI_API_KEY),
89
- apiEndpoint: settings.OPENAI_API_ENDPOINT,
86
+ ai: {
87
+ apiKey: maskSecret(settings.AI_API_KEY),
88
+ apiEndpoint: settings.AI_API_ENDPOINT,
89
+ model: settings.AI_MODEL,
90
+ responseLanguage: settings.AI_RESPONSE_LANGUAGE,
90
91
  },
91
92
  telegram: {
92
93
  botToken: maskSecret(settings.TG_BOT_TOKEN),
@@ -97,12 +98,22 @@ const toResponse = (settings: UserSettings) => ({
97
98
 
98
99
  const hasKeys = (patch: Partial<UserRecord>) => Object.keys(patch).length > 0;
99
100
 
101
+ const removeLegacyPasswordlessToken = async (userName: string) => {
102
+ const record = await getUserRecord(userName);
103
+ if (!record || !Object.hasOwn(record, 'token')) {
104
+ return;
105
+ }
106
+
107
+ await updateUserRecord(userName, { token: undefined });
108
+ };
109
+
100
110
  export const GET = async () => {
101
111
  const userName = await getCurrentUserName();
102
112
  if (!userName) {
103
113
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
104
114
  }
105
115
 
116
+ await removeLegacyPasswordlessToken(userName);
106
117
  const settings = await getUserSettings(userName);
107
118
  return NextResponse.json(toResponse(settings));
108
119
  };
@@ -113,6 +124,7 @@ export const PATCH = async (request: Request) => {
113
124
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
114
125
  }
115
126
 
127
+ await removeLegacyPasswordlessToken(userName);
116
128
  const body = (await request.json()) as UpdateBody | null;
117
129
  if (!body || typeof body !== 'object' || !('section' in body)) {
118
130
  return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
@@ -160,14 +172,6 @@ export const PATCH = async (request: Request) => {
160
172
  }
161
173
  }
162
174
 
163
- if (body.section === 'token') {
164
- const token = cleanOptionalText(body.data?.token);
165
-
166
- if (token) {
167
- await updateUserRecord(userName, { token });
168
- }
169
- }
170
-
171
175
  if (body.section === 'coinalyze') {
172
176
  const apiKey = cleanOptionalText(body.data?.apiKey);
173
177
 
@@ -176,17 +180,41 @@ export const PATCH = async (request: Request) => {
176
180
  }
177
181
  }
178
182
 
179
- if (body.section === 'openai') {
183
+ if (body.section === 'ai') {
184
+ const currentSettings = await getUserSettings(userName);
180
185
  const patch: Partial<UserRecord> = {};
181
186
  const apiKey = cleanOptionalText(body.data?.apiKey);
182
- const apiEndpoint = cleanOptionalText(body.data?.apiEndpoint);
187
+ const apiEndpoint = normalizeAiEndpoint(body.data?.apiEndpoint);
188
+ const effectiveEndpoint = apiEndpoint || currentSettings.AI_API_ENDPOINT;
189
+ const responseLanguage = normalizeAiResponseLanguage(
190
+ body.data?.responseLanguage,
191
+ );
183
192
 
184
193
  if (apiKey) {
185
- patch.OPENAI_API_KEY = apiKey;
194
+ patch.AI_API_KEY = apiKey;
195
+ }
196
+
197
+ if (body.data && 'apiEndpoint' in body.data && !apiEndpoint) {
198
+ return NextResponse.json(
199
+ { error: 'Invalid AI API endpoint URL' },
200
+ { status: 400 },
201
+ );
186
202
  }
187
203
 
188
204
  if (apiEndpoint) {
189
- patch.OPENAI_API_ENDPOINT = apiEndpoint;
205
+ patch.AI_API_ENDPOINT = apiEndpoint;
206
+ }
207
+
208
+ if (
209
+ body.data &&
210
+ ('apiEndpoint' in body.data || 'model' in body.data) &&
211
+ effectiveEndpoint
212
+ ) {
213
+ patch.AI_MODEL = normalizeAiModel(body.data?.model, effectiveEndpoint);
214
+ }
215
+
216
+ if (body.data && 'responseLanguage' in body.data) {
217
+ patch.AI_RESPONSE_LANGUAGE = responseLanguage;
190
218
  }
191
219
 
192
220
  if (hasKeys(patch)) {