@tradejs/app 1.0.5 → 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.
Files changed (37) hide show
  1. package/bin/tradejs-app.mjs +129 -20
  2. package/package.json +13 -12
  3. package/public/auth-bg.jpg +0 -0
  4. package/public/next.svg +1 -0
  5. package/public/og-image-source.svg +91 -0
  6. package/public/og-image.png +0 -0
  7. package/public/vercel.svg +1 -0
  8. package/src/app/api/ai/route.ts +84 -20
  9. package/src/app/api/backtest/files/route.ts +12 -1
  10. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +18 -1
  11. package/src/app/api/kline/[provider]/[symbol]/[interval]/route.ts +357 -29
  12. package/src/app/api/scanner/[provider]/route.ts +7 -1
  13. package/src/app/api/scanner/route.ts +7 -1
  14. package/src/app/api/signal/[symbol]/[signalId]/route.ts +6 -0
  15. package/src/app/api/user/settings/route.ts +244 -0
  16. package/src/app/components/Dashboard/AiDrawer/index.tsx +38 -51
  17. package/src/app/components/Shared/Filters/Backtest/index.tsx +12 -5
  18. package/src/app/components/Shared/Filters/Root/index.tsx +12 -1
  19. package/src/app/components/Shared/Filters/Symbol/index.tsx +14 -19
  20. package/src/app/components/Shared/Filters/context.ts +2 -0
  21. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +948 -0
  22. package/src/app/components/Shared/Sidebar/index.tsx +13 -9
  23. package/src/app/components/UI/ColorMode/index.tsx +62 -15
  24. package/src/app/components/UI/Select/index.tsx +3 -0
  25. package/src/app/components/UI/SelectWithSearch/index.tsx +3 -0
  26. package/src/app/globals.css +11 -0
  27. package/src/app/layout.tsx +50 -11
  28. package/src/app/lib/currentUser.ts +27 -0
  29. package/src/app/lib/klineWindow.ts +17 -0
  30. package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +10 -2
  31. package/src/app/routes/signin/page.tsx +11 -2
  32. package/src/app/store/ai.ts +174 -0
  33. package/src/app/store/data.ts +219 -88
  34. package/src/app/store/index.ts +1 -0
  35. package/src/app/store/tests.ts +96 -9
  36. package/src/app/store/tickers.ts +113 -17
  37. package/src/proxy.ts +23 -50
@@ -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,
@@ -12,10 +13,15 @@ import {
12
13
  Interval,
13
14
  ConnectorCreator,
14
15
  } from '@tradejs/types';
16
+ import { getCurrentUserName } from '@app/lib/currentUser';
17
+ import { normalizeEndToIntervalBoundary } from '@app/lib/klineWindow';
15
18
 
16
19
  export const dynamic = 'force-dynamic';
17
20
  const projectRoot =
18
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;
19
25
 
20
26
  interface Params {
21
27
  provider: string;
@@ -23,6 +29,193 @@ interface Params {
23
29
  interval: string;
24
30
  }
25
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
+
26
219
  const enrichWithPluginIndicators = (
27
220
  data: KlineChartData,
28
221
  btcData: KlineChartData,
@@ -37,7 +230,7 @@ const enrichWithPluginIndicators = (
37
230
  pluginRegistryScope: projectRoot,
38
231
  }).result() as Record<string, number[]>;
39
232
 
40
- const nextData = data.map((candle) => ({ ...candle }));
233
+ const nextData = cloneKlineData(data);
41
234
 
42
235
  for (const pluginKey of pluginKeys) {
43
236
  const series = history[pluginKey];
@@ -62,11 +255,37 @@ const enrichWithPluginIndicators = (
62
255
  return nextData;
63
256
  };
64
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
+
65
279
  export const POST = async (
66
280
  request: NextRequest,
67
281
  { params }: { params: Promise<Params> },
68
282
  ) => {
69
283
  try {
284
+ const userName = await getCurrentUserName();
285
+ if (!userName) {
286
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
287
+ }
288
+
70
289
  const { provider, symbol, interval } = await params;
71
290
  const body = await request.json();
72
291
  const options = body as
@@ -80,42 +299,151 @@ export const POST = async (
80
299
  );
81
300
  }
82
301
 
83
- const connectorCreator =
84
- (await getConnectorCreatorByProvider(provider, projectRoot)) ||
85
- (await getConnectorCreatorByProvider('bybit', projectRoot));
86
- if (!connectorCreator) {
87
- throw new Error('No connector available for provider');
88
- }
89
- const connector = await (connectorCreator as ConnectorCreator)({
90
- userName: 'root',
91
- });
92
-
93
- const baseData = await connector.kline({
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({
310
+ userName,
311
+ provider,
94
312
  symbol,
95
- interval: interval as Interval,
96
- ...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,
97
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
+ }
98
327
 
99
- await ensureIndicatorPluginsLoaded(projectRoot);
100
- const pluginKeys = getRegisteredIndicatorEntries(projectRoot).map(
101
- (entry) => entry.historyKey || entry.indicator.id,
102
- );
103
- if (!pluginKeys.length) {
104
- 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 });
105
332
  }
106
333
 
107
- const btcData =
108
- symbol === 'BTCUSDT'
109
- ? baseData
110
- : await connector.kline({
111
- symbol: 'BTCUSDT',
112
- 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,
113
398
  ...options,
114
- });
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
+ });
115
443
 
116
- const data = enrichWithPluginIndicators(baseData, btcData, pluginKeys);
444
+ inflightRequests.set(requestKey, pending);
117
445
 
118
- return NextResponse.json({ data });
446
+ return NextResponse.json({ data: await pending });
119
447
  } catch (error) {
120
448
  logger.log('error', `Kline fetch error: %o`, error);
121
449
  return NextResponse.json(
@@ -3,6 +3,7 @@ import { ConnectorCreator } from '@tradejs/types';
3
3
  import { getConnectorCreatorByProvider } from '@tradejs/node/connectors';
4
4
  import { getTopTickers } from '@tradejs/core/tickers';
5
5
  import { logger } from '@tradejs/infra/logger';
6
+ import { getCurrentUserName } from '@app/lib/currentUser';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
8
9
  const projectRoot =
@@ -17,6 +18,11 @@ export const GET = async (
17
18
  { params }: { params: Promise<Params> },
18
19
  ) => {
19
20
  try {
21
+ const userName = await getCurrentUserName();
22
+ if (!userName) {
23
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24
+ }
25
+
20
26
  const { provider } = await params;
21
27
  const connectorCreator =
22
28
  (await getConnectorCreatorByProvider(provider, projectRoot)) ||
@@ -26,7 +32,7 @@ export const GET = async (
26
32
  }
27
33
 
28
34
  const connector = await (connectorCreator as ConnectorCreator)({
29
- userName: 'root',
35
+ userName,
30
36
  });
31
37
 
32
38
  const data = await connector.getTickers();
@@ -3,6 +3,7 @@ import { getTopTickers } from '@tradejs/core/tickers';
3
3
  import { getConnectorCreatorByProvider } from '@tradejs/node/connectors';
4
4
  import { logger } from '@tradejs/infra/logger';
5
5
  import { ConnectorCreator } from '@tradejs/types';
6
+ import { getCurrentUserName } from '@app/lib/currentUser';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
8
9
  const projectRoot =
@@ -10,6 +11,11 @@ const projectRoot =
10
11
 
11
12
  export const GET = async () => {
12
13
  try {
14
+ const userName = await getCurrentUserName();
15
+ if (!userName) {
16
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
17
+ }
18
+
13
19
  const connectorCreator = await getConnectorCreatorByProvider(
14
20
  'bybit',
15
21
  projectRoot,
@@ -19,7 +25,7 @@ export const GET = async () => {
19
25
  }
20
26
 
21
27
  const byBitConnector = await (connectorCreator as ConnectorCreator)({
22
- userName: 'root',
28
+ userName,
23
29
  });
24
30
 
25
31
  const data = await byBitConnector.getTickers();
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
3
3
  import { getData, redisKeys } from '@tradejs/infra/redis';
4
4
  import { logger } from '@tradejs/infra/logger';
5
5
  import { Signal } from '@tradejs/types';
6
+ import { getCurrentUserName } from '@app/lib/currentUser';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
8
9
 
@@ -16,6 +17,11 @@ export const GET = async (
16
17
  { params }: { params: Promise<Params> },
17
18
  ) => {
18
19
  try {
20
+ const userName = await getCurrentUserName();
21
+ if (!userName) {
22
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
23
+ }
24
+
19
25
  const { symbol, signalId } = await params;
20
26
 
21
27
  if (!symbol || !signalId) {