@tradejs/app 3.1.3 → 3.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tradejs/app",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
4
4
  "description": "Installable Next.js UI for the TradeJS TypeScript framework: dashboards, backtests, charts, and runtime data.",
5
5
  "keywords": [
6
6
  "tradejs",
@@ -52,11 +52,11 @@
52
52
  "@chakra-ui/charts": "3.36.1",
53
53
  "@chakra-ui/react": "3.36.1",
54
54
  "@emotion/react": "^11.14.0",
55
- "@tradejs/core": "^3.1.3",
56
- "@tradejs/indicators": "^3.1.3",
57
- "@tradejs/infra": "^3.1.3",
58
- "@tradejs/node": "^3.1.3",
59
- "@tradejs/types": "^3.1.3",
55
+ "@tradejs/core": "^3.1.5",
56
+ "@tradejs/indicators": "^3.1.5",
57
+ "@tradejs/infra": "^3.1.5",
58
+ "@tradejs/node": "^3.1.5",
59
+ "@tradejs/types": "^3.1.5",
60
60
  "@types/bcryptjs": "2.4.6",
61
61
  "@types/lodash": "4.17.24",
62
62
  "@types/node": "24.13.3",
@@ -42,13 +42,13 @@ export const PATCH = async (
42
42
  const reference = deployment.strategies.find(
43
43
  (strategy) => strategy.strategyName === strategyName,
44
44
  );
45
- if (!reference?.releaseVersion) {
45
+ if (!reference) {
46
46
  return NextResponse.json(
47
47
  { error: 'Pause/resume requires a versioned strategy release' },
48
48
  { status: 400 },
49
49
  );
50
50
  }
51
- const previousState = reference.controlState ?? 'active';
51
+ const previousState = reference.controlState;
52
52
  if (previousState === controlState) {
53
53
  return NextResponse.json({ deployment, controlState });
54
54
  }
@@ -6,11 +6,33 @@ import {
6
6
  } from '@tradejs/infra/runtimeDeployments';
7
7
  import { getTradingAccount } from '@tradejs/infra/tradingAccounts';
8
8
  import { getRuntimeStrategyRelease } from '@tradejs/infra/runtimeStrategyReleases';
9
- import { isMarketUniverse, type RuntimeDeployment } from '@tradejs/types';
9
+ import type {
10
+ MarketUniverse,
11
+ RuntimeDeployment,
12
+ RuntimeDeploymentStrategy,
13
+ } from '@tradejs/types';
10
14
  import { getCurrentUserName } from '#app/lib/currentUser';
11
15
 
12
16
  export const dynamic = 'force-dynamic';
13
17
 
18
+ const isRuntimeStrategyReference = (
19
+ value: unknown,
20
+ ): value is RuntimeDeploymentStrategy => {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
22
+ const record = value as Record<string, unknown>;
23
+ return (
24
+ typeof record.strategyName === 'string' &&
25
+ record.strategyName.trim().length > 0 &&
26
+ Number.isSafeInteger(record.releaseVersion) &&
27
+ Number(record.releaseVersion) > 0 &&
28
+ (record.controlState === 'active' ||
29
+ record.controlState === 'entries_paused') &&
30
+ Object.keys(record).every((key) =>
31
+ ['strategyName', 'releaseVersion', 'controlState'].includes(key),
32
+ )
33
+ );
34
+ };
35
+
14
36
  export const GET = async () => {
15
37
  const userName = await getCurrentUserName();
16
38
  if (!userName) {
@@ -33,60 +55,38 @@ export const POST = async (request: NextRequest) => {
33
55
  }
34
56
  try {
35
57
  const body = (await request.json()) as Partial<RuntimeDeployment>;
36
- const hasVersionedStrategies = Boolean(
37
- body.strategies?.some((strategy) => strategy.releaseVersion != null),
38
- );
39
58
  if (
40
- hasVersionedStrategies &&
41
- body.strategies?.some((strategy) => strategy.releaseVersion == null)
59
+ !Array.isArray(body.strategies) ||
60
+ !body.strategies.length ||
61
+ !body.strategies.every(isRuntimeStrategyReference)
42
62
  ) {
43
63
  return NextResponse.json(
44
- { error: 'A deployment cannot mix legacy configs and releases' },
64
+ { error: 'Invalid runtime strategy reference' },
45
65
  { status: 400 },
46
66
  );
47
67
  }
48
- const releases = hasVersionedStrategies
49
- ? await Promise.all(
50
- (body.strategies ?? []).map(async (strategy) => {
51
- if (
52
- !Number.isSafeInteger(strategy.releaseVersion) ||
53
- !strategy.releaseVersion ||
54
- (strategy.config && Object.keys(strategy.config).length)
55
- ) {
56
- throw new Error('Invalid versioned strategy reference');
57
- }
58
- const release = await getRuntimeStrategyRelease(
59
- userName,
60
- strategy.strategyName,
61
- strategy.releaseVersion,
62
- );
63
- if (!release) {
64
- throw new Error(
65
- `Release not found: ${strategy.strategyName} v${strategy.releaseVersion}`,
66
- );
67
- }
68
- return release;
69
- }),
70
- )
71
- : [];
72
- const universe = releases[0]?.config.UNIVERSE ?? body.universe;
73
- const interval = releases[0]?.config.INTERVAL ?? body.interval;
68
+ const releases = await Promise.all(
69
+ body.strategies.map(async (strategy) => {
70
+ const release = await getRuntimeStrategyRelease(
71
+ userName,
72
+ strategy.strategyName,
73
+ strategy.releaseVersion,
74
+ );
75
+ if (!release) {
76
+ throw new Error(
77
+ `Release not found: ${strategy.strategyName} v${strategy.releaseVersion}`,
78
+ );
79
+ }
80
+ return release;
81
+ }),
82
+ );
74
83
  if (
75
84
  !body.id ||
76
85
  !body.label ||
77
86
  !body.connectorName ||
78
87
  !body.provider ||
79
88
  !body.accountId ||
80
- !interval ||
81
- !isMarketUniverse(universe) ||
82
- !Array.isArray(body.strategies) ||
83
- !body.strategies.length ||
84
- body.strategies.some((strategy) =>
85
- hasVersionedStrategies
86
- ? !String(strategy.strategyName ?? '').trim()
87
- : !String(strategy.strategyName ?? '').trim() ||
88
- !String(strategy.policyProfileId ?? '').trim(),
89
- )
89
+ typeof body.enabled !== 'boolean'
90
90
  ) {
91
91
  return NextResponse.json(
92
92
  { error: 'Invalid runtime deployment' },
@@ -104,9 +104,14 @@ export const POST = async (request: NextRequest) => {
104
104
  { status: 400 },
105
105
  );
106
106
  }
107
- if (!account.universes.includes(universe)) {
107
+ const unsupportedUniverse = releases
108
+ .map((release) => release.config.UNIVERSE as MarketUniverse)
109
+ .find((universe) => !account.universes.includes(universe));
110
+ if (unsupportedUniverse) {
108
111
  return NextResponse.json(
109
- { error: `Account ${account.id} does not support ${universe}` },
112
+ {
113
+ error: `Account ${account.id} does not support ${unsupportedUniverse}`,
114
+ },
110
115
  { status: 400 },
111
116
  );
112
117
  }
@@ -116,18 +121,8 @@ export const POST = async (request: NextRequest) => {
116
121
  connectorName: body.connectorName,
117
122
  provider: body.provider,
118
123
  accountId: body.accountId,
119
- universe,
120
- interval: String(interval),
121
- enabled: body.enabled !== false,
122
- strategies: body.strategies.map((strategy) =>
123
- hasVersionedStrategies
124
- ? {
125
- strategyName: strategy.strategyName,
126
- releaseVersion: strategy.releaseVersion,
127
- controlState: strategy.controlState ?? 'active',
128
- }
129
- : strategy,
130
- ),
124
+ enabled: body.enabled,
125
+ strategies: body.strategies,
131
126
  assetClasses: body.assetClasses,
132
127
  tickers: body.tickers,
133
128
  });
@@ -18,13 +18,6 @@ import { getCurrentUserName } from '#app/lib/currentUser';
18
18
  export const dynamic = 'force-dynamic';
19
19
 
20
20
  type UpdateBody =
21
- | {
22
- section: 'bybit';
23
- data?: {
24
- apiKey?: string;
25
- apiSecret?: string;
26
- };
27
- }
28
21
  | {
29
22
  section: 'coinalyze';
30
23
  data?: {
@@ -61,6 +54,14 @@ type UpdateBody =
61
54
  };
62
55
  };
63
56
 
57
+ const UPDATE_SECTIONS = new Set([
58
+ 'coinalyze',
59
+ 'coinmarketcap',
60
+ 'ai',
61
+ 'telegram',
62
+ 'password',
63
+ ]);
64
+
64
65
  const cleanText = (value: unknown): string =>
65
66
  typeof value === 'string' ? value.trim() : '';
66
67
 
@@ -99,10 +100,6 @@ const toResponse = (rawSettings: UserSettings) => {
99
100
  return {
100
101
  userName: settings.userName,
101
102
  settings: {
102
- bybit: {
103
- apiKey: maskSecret(settings.BYBIT_API_KEY),
104
- apiSecret: maskSecret(settings.BYBIT_API_SECRET),
105
- },
106
103
  coinalyze: {
107
104
  apiKey: maskSecret(settings.COINALYZE_API_KEY),
108
105
  },
@@ -153,7 +150,12 @@ export const PATCH = async (request: Request) => {
153
150
 
154
151
  await removeLegacyPasswordlessToken(userName);
155
152
  const body = (await request.json()) as UpdateBody | null;
156
- if (!body || typeof body !== 'object' || !('section' in body)) {
153
+ if (
154
+ !body ||
155
+ typeof body !== 'object' ||
156
+ !('section' in body) ||
157
+ !UPDATE_SECTIONS.has(String(body.section))
158
+ ) {
157
159
  return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
158
160
  }
159
161
 
@@ -181,24 +183,6 @@ export const PATCH = async (request: Request) => {
181
183
  return NextResponse.json(toResponse(settings));
182
184
  }
183
185
 
184
- if (body.section === 'bybit') {
185
- const patch: Partial<UserRecord> = {};
186
- const apiKey = cleanOptionalText(body.data?.apiKey);
187
- const apiSecret = cleanOptionalText(body.data?.apiSecret);
188
-
189
- if (apiKey) {
190
- patch.BYBIT_API_KEY = apiKey;
191
- }
192
-
193
- if (apiSecret) {
194
- patch.BYBIT_API_SECRET = apiSecret;
195
- }
196
-
197
- if (hasKeys(patch)) {
198
- await updateUserRecord(userName, patch);
199
- }
200
- }
201
-
202
186
  if (body.section === 'coinalyze') {
203
187
  const apiKey = cleanOptionalText(body.data?.apiKey);
204
188
 
@@ -4,7 +4,6 @@ import {
4
4
  listTradingAccounts,
5
5
  saveTradingAccount,
6
6
  } from '@tradejs/infra/tradingAccounts';
7
- import { getUserSettings } from '@tradejs/infra/userSettings';
8
7
  import { isMarketUniverse, type TradingAccountRef } from '@tradejs/types';
9
8
  import { getCurrentUserName } from '#app/lib/currentUser';
10
9
 
@@ -26,52 +25,12 @@ const toPublicAccount = (account: TradingAccountRef) => {
26
25
  };
27
26
  };
28
27
 
29
- const migrateLegacyBybitAccount = async (
30
- userName: string,
31
- accounts: TradingAccountRef[],
32
- ) => {
33
- const upgradedAccounts = await Promise.all(
34
- accounts.map((account) =>
35
- account.provider === 'bybit' && !account.universes?.includes('tradfi')
36
- ? saveTradingAccount(userName, {
37
- ...account,
38
- universes: ['crypto', 'tradfi'],
39
- })
40
- : account,
41
- ),
42
- );
43
- if (upgradedAccounts.some(({ provider }) => provider === 'bybit')) {
44
- return upgradedAccounts;
45
- }
46
- const settings = await getUserSettings(userName);
47
- if (!settings.BYBIT_API_KEY || !settings.BYBIT_API_SECRET) {
48
- return upgradedAccounts;
49
- }
50
- const migrated = await saveTradingAccount(userName, {
51
- id: 'bybit-default',
52
- label: 'Bybit',
53
- provider: 'bybit',
54
- enabled: true,
55
- isDefault: true,
56
- universes: ['crypto', 'tradfi'],
57
- environment: 'mainnet',
58
- apiKey: settings.BYBIT_API_KEY,
59
- apiSecret: settings.BYBIT_API_SECRET,
60
- });
61
- return [...upgradedAccounts, migrated].sort((left, right) =>
62
- left.label.localeCompare(right.label),
63
- );
64
- };
65
-
66
28
  export const GET = async () => {
67
29
  const userName = await getCurrentUserName();
68
30
  if (!userName) {
69
31
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
70
32
  }
71
- const accounts = await migrateLegacyBybitAccount(
72
- userName,
73
- await listTradingAccounts(userName),
74
- );
33
+ const accounts = await listTradingAccounts(userName);
75
34
  return NextResponse.json({ accounts: accounts.map(toPublicAccount) });
76
35
  };
77
36
 
@@ -38,10 +38,6 @@ import { TradingAccountsPanel } from './TradingAccountsPanel';
38
38
  type SettingsResponse = {
39
39
  userName: string;
40
40
  settings: {
41
- bybit: {
42
- apiKey: string;
43
- apiSecret: string;
44
- };
45
41
  coinalyze: {
46
42
  apiKey: string;
47
43
  };
@@ -67,8 +63,6 @@ type SettingsErrorResponse = {
67
63
 
68
64
  type SettingsViewState = {
69
65
  userName: string;
70
- bybitApiKey: string;
71
- bybitApiSecret: string;
72
66
  coinalyzeApiKey: string;
73
67
  coinmarketcapApiKey: string;
74
68
  aiApiKey: string;
@@ -87,15 +81,12 @@ type PasswordState = {
87
81
  };
88
82
 
89
83
  type SectionName =
90
- | 'bybit'
91
84
  | 'password'
92
85
  | 'coinalyze'
93
86
  | 'coinmarketcap'
94
87
  | 'ai'
95
88
  | 'telegram';
96
89
  type EditableField =
97
- | 'bybitApiKey'
98
- | 'bybitApiSecret'
99
90
  | 'coinalyzeApiKey'
100
91
  | 'coinmarketcapApiKey'
101
92
  | 'aiApiKey'
@@ -107,8 +98,6 @@ type EditableField =
107
98
 
108
99
  const EMPTY_SETTINGS: SettingsViewState = {
109
100
  userName: '',
110
- bybitApiKey: '',
111
- bybitApiSecret: '',
112
101
  coinalyzeApiKey: '',
113
102
  coinmarketcapApiKey: '',
114
103
  aiApiKey: '',
@@ -120,8 +109,6 @@ const EMPTY_SETTINGS: SettingsViewState = {
120
109
  };
121
110
 
122
111
  const EMPTY_DRAFTS: SettingsDraftState = {
123
- bybitApiKey: '',
124
- bybitApiSecret: '',
125
112
  coinalyzeApiKey: '',
126
113
  coinmarketcapApiKey: '',
127
114
  aiApiKey: '',
@@ -138,8 +125,6 @@ const EMPTY_PASSWORDS: PasswordState = {
138
125
  };
139
126
 
140
127
  const EMPTY_EDITING: Record<EditableField, boolean> = {
141
- bybitApiKey: false,
142
- bybitApiSecret: false,
143
128
  coinalyzeApiKey: false,
144
129
  coinmarketcapApiKey: false,
145
130
  aiApiKey: false,
@@ -150,20 +135,7 @@ const EMPTY_EDITING: Record<EditableField, boolean> = {
150
135
  tgChatId: false,
151
136
  };
152
137
 
153
- const SECTION_FIELDS: Record<
154
- Exclude<SectionName, 'password'>,
155
- EditableField[]
156
- > = {
157
- bybit: ['bybitApiKey', 'bybitApiSecret'],
158
- coinalyze: ['coinalyzeApiKey'],
159
- coinmarketcap: ['coinmarketcapApiKey'],
160
- ai: ['aiApiKey', 'aiApiEndpoint', 'aiModel', 'aiResponseLanguage'],
161
- telegram: ['tgBotToken', 'tgChatId'],
162
- };
163
-
164
138
  const MASKED_FIELDS = new Set<EditableField>([
165
- 'bybitApiKey',
166
- 'bybitApiSecret',
167
139
  'coinalyzeApiKey',
168
140
  'coinmarketcapApiKey',
169
141
  'aiApiKey',
@@ -172,8 +144,6 @@ const MASKED_FIELDS = new Set<EditableField>([
172
144
 
173
145
  const toViewState = (payload: SettingsResponse): SettingsViewState => ({
174
146
  userName: payload.userName,
175
- bybitApiKey: payload.settings.bybit.apiKey || '',
176
- bybitApiSecret: payload.settings.bybit.apiSecret || '',
177
147
  coinalyzeApiKey: payload.settings.coinalyze.apiKey || '',
178
148
  coinmarketcapApiKey: payload.settings.coinmarketcap.apiKey || '',
179
149
  aiApiKey: payload.settings.ai.apiKey || '',
@@ -349,9 +319,7 @@ export const AccountSettingsDrawer = () => {
349
319
  );
350
320
  }
351
321
 
352
- return SECTION_FIELDS[section].some((field) =>
353
- Boolean(drafts[field].trim()),
354
- );
322
+ return false;
355
323
  },
356
324
  [drafts, passwords.confirmPassword, passwords.password, settings],
357
325
  );
@@ -437,56 +405,48 @@ export const AccountSettingsDrawer = () => {
437
405
 
438
406
  try {
439
407
  const body =
440
- section === 'bybit'
408
+ section === 'coinalyze'
441
409
  ? {
442
410
  section,
443
411
  data: {
444
- apiKey: getSecretUpdateValue('bybitApiKey'),
445
- apiSecret: getSecretUpdateValue('bybitApiSecret'),
412
+ apiKey: getSecretUpdateValue('coinalyzeApiKey'),
446
413
  },
447
414
  }
448
- : section === 'coinalyze'
415
+ : section === 'ai'
449
416
  ? {
450
417
  section,
451
418
  data: {
452
- apiKey: getSecretUpdateValue('coinalyzeApiKey'),
419
+ apiKey: getSecretUpdateValue('aiApiKey'),
420
+ apiEndpoint: drafts.aiApiEndpoint,
421
+ model: drafts.aiModel.trim(),
422
+ responseLanguage: drafts.aiResponseLanguage,
453
423
  },
454
424
  }
455
- : section === 'ai'
425
+ : section === 'coinmarketcap'
456
426
  ? {
457
427
  section,
458
428
  data: {
459
- apiKey: getSecretUpdateValue('aiApiKey'),
460
- apiEndpoint: drafts.aiApiEndpoint,
461
- model: drafts.aiModel.trim(),
462
- responseLanguage: drafts.aiResponseLanguage,
429
+ apiKey: getSecretUpdateValue('coinmarketcapApiKey'),
463
430
  },
464
431
  }
465
- : section === 'coinmarketcap'
432
+ : section === 'telegram'
466
433
  ? {
467
434
  section,
468
435
  data: {
469
- apiKey: getSecretUpdateValue('coinmarketcapApiKey'),
436
+ botToken: getSecretUpdateValue('tgBotToken'),
437
+ chatId:
438
+ drafts.tgChatId !== settings.tgChatId
439
+ ? drafts.tgChatId.trim()
440
+ : undefined,
470
441
  },
471
442
  }
472
- : section === 'telegram'
473
- ? {
474
- section,
475
- data: {
476
- botToken: getSecretUpdateValue('tgBotToken'),
477
- chatId:
478
- drafts.tgChatId !== settings.tgChatId
479
- ? drafts.tgChatId.trim()
480
- : undefined,
481
- },
482
- }
483
- : {
484
- section,
485
- data: {
486
- password: passwords.password,
487
- confirmPassword: passwords.confirmPassword,
488
- },
489
- };
443
+ : {
444
+ section,
445
+ data: {
446
+ password: passwords.password,
447
+ confirmPassword: passwords.confirmPassword,
448
+ },
449
+ };
490
450
 
491
451
  const response = await fetch('/api/user/settings', {
492
452
  method: 'PATCH',
@@ -72,7 +72,6 @@ export const RuntimeStrategyCard = ({
72
72
  );
73
73
  const { lastTrade, runtimeOrders } = viewModel;
74
74
  const setControlState = async (controlState: 'active' | 'entries_paused') => {
75
- if (!strategy.deploymentId || !strategy.releaseVersion) return;
76
75
  setControlSaving(true);
77
76
  try {
78
77
  const response = await fetch(
@@ -135,25 +134,17 @@ export const RuntimeStrategyCard = ({
135
134
  <Badge colorPalette="cyan" variant="outline">
136
135
  TF: {strategy.interval}m
137
136
  </Badge>
138
- {strategy.releaseVersion ? (
139
- <Badge colorPalette="gray" variant="outline">
140
- release: v{strategy.releaseVersion}
141
- </Badge>
142
- ) : (
143
- <Badge colorPalette="gray" variant="outline">
144
- legacy config: {strategy.configId}
145
- </Badge>
146
- )}
137
+ <Badge colorPalette="gray" variant="outline">
138
+ release: v{strategy.releaseVersion}
139
+ </Badge>
147
140
  {strategy.accountId ? (
148
141
  <Badge colorPalette="purple" variant="outline">
149
142
  account: {strategy.accountLabel ?? strategy.accountId}
150
143
  </Badge>
151
144
  ) : null}
152
- {strategy.deploymentId ? (
153
- <Badge colorPalette="orange" variant="outline">
154
- deployment: {strategy.deploymentId}
155
- </Badge>
156
- ) : null}
145
+ <Badge colorPalette="orange" variant="outline">
146
+ deployment: {strategy.deploymentId}
147
+ </Badge>
157
148
  {strategy.policyProfileId ? (
158
149
  <Badge colorPalette="cyan" variant="outline">
159
150
  policy: {strategy.policyProfileId}
@@ -211,28 +202,24 @@ export const RuntimeStrategyCard = ({
211
202
  <Portal>
212
203
  <Menu.Positioner>
213
204
  <Menu.Content minW="160px">
214
- {strategy.connected ? (
215
- <Menu.Item value="edit" onClick={() => setConfigOpen(true)}>
216
- {strategy.releaseVersion ? 'View config' : 'Edit'}
217
- </Menu.Item>
218
- ) : null}
219
- {strategy.releaseVersion && strategy.deploymentId ? (
220
- <Menu.Item
221
- value="control"
222
- disabled={controlSaving}
223
- onClick={() =>
224
- void setControlState(
225
- strategy.controlState === 'entries_paused'
226
- ? 'active'
227
- : 'entries_paused',
228
- )
229
- }
230
- >
231
- {strategy.controlState === 'entries_paused'
232
- ? 'Resume entries'
233
- : 'Pause new entries'}
234
- </Menu.Item>
235
- ) : null}
205
+ <Menu.Item value="config" onClick={() => setConfigOpen(true)}>
206
+ View config
207
+ </Menu.Item>
208
+ <Menu.Item
209
+ value="control"
210
+ disabled={controlSaving}
211
+ onClick={() =>
212
+ void setControlState(
213
+ strategy.controlState === 'entries_paused'
214
+ ? 'active'
215
+ : 'entries_paused',
216
+ )
217
+ }
218
+ >
219
+ {strategy.controlState === 'entries_paused'
220
+ ? 'Resume entries'
221
+ : 'Pause new entries'}
222
+ </Menu.Item>
236
223
  <Menu.Item value="orders" onClick={() => setOrdersOpen(true)}>
237
224
  Orders
238
225
  </Menu.Item>
@@ -258,7 +245,6 @@ export const RuntimeStrategyCard = ({
258
245
  open={configOpen}
259
246
  strategy={strategy}
260
247
  onOpenChange={setConfigOpen}
261
- onSaved={onUpdated}
262
248
  />
263
249
 
264
250
  <RuntimeStrategyStatsDrawer