@tradejs/app 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.
- package/bin/tradejs-app.mjs +7 -0
- package/package.json +12 -12
- package/src/app/api/ai/route.ts +63 -20
- package/src/app/api/backtest/files/route.ts +12 -1
- package/src/app/api/backtest/test/[strategy]/[name]/route.ts +18 -1
- package/src/app/api/kline/[provider]/[symbol]/[interval]/route.ts +350 -28
- package/src/app/api/signal/[symbol]/[signalId]/route.ts +6 -0
- package/src/app/api/user/settings/route.ts +51 -23
- package/src/app/components/Dashboard/AiDrawer/index.tsx +38 -51
- package/src/app/components/Shared/Filters/Backtest/index.tsx +12 -5
- package/src/app/components/Shared/Filters/Root/index.tsx +12 -1
- package/src/app/components/Shared/Filters/Symbol/index.tsx +14 -19
- package/src/app/components/Shared/Filters/context.ts +2 -0
- package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +250 -111
- package/src/app/components/UI/ColorMode/index.tsx +62 -15
- package/src/app/components/UI/Select/index.tsx +3 -0
- package/src/app/components/UI/SelectWithSearch/index.tsx +3 -0
- package/src/app/layout.tsx +10 -8
- package/src/app/lib/klineWindow.ts +17 -0
- package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +10 -2
- package/src/app/store/ai.ts +174 -0
- package/src/app/store/data.ts +34 -8
- package/src/app/store/index.ts +1 -0
- package/src/app/store/tests.ts +96 -9
- package/src/app/store/tickers.ts +113 -17
- 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,
|
|
@@ -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
|
|
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
|
|
90
|
-
|
|
91
|
-
(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
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:
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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(
|
|
@@ -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) {
|
|
@@ -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: '
|
|
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
|
-
|
|
88
|
-
apiKey: maskSecret(settings.
|
|
89
|
-
apiEndpoint: settings.
|
|
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 === '
|
|
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 =
|
|
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.
|
|
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.
|
|
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)) {
|