@tradejs/app 1.0.8 → 1.0.10
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/README.md +7 -2
- package/bin/tradejs-app.mjs +146 -2
- package/next.config.mjs +0 -14
- package/package.json +35 -19
- package/src/app/actions/backtest.ts +54 -0
- package/src/app/actions/kline.ts +10 -5
- package/src/app/actions/scanner.ts +8 -3
- package/src/app/actions/strategies.ts +39 -0
- package/src/app/api/ai/route.ts +5 -4
- package/src/app/api/auth/[...nextauth]/route.ts +1 -1
- package/src/app/api/backtest/configs/route.ts +25 -0
- package/src/app/api/backtest/files/route.ts +5 -2
- package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
- package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
- package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
- package/src/app/api/backtest/runs/route.ts +45 -0
- package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
- package/src/app/api/derivatives/summary/route.ts +8 -1
- package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
- package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
- package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
- package/src/app/api/scanner/[provider]/route.ts +30 -12
- package/src/app/api/scanner/route.ts +3 -3
- package/src/app/api/signal/[symbol]/[signalId]/route.ts +4 -7
- package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
- package/src/app/api/strategies/ai/route.ts +139 -0
- package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
- package/src/app/api/strategies/replay/route.ts +44 -0
- package/src/app/api/strategies/runtime/route.ts +831 -0
- package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
- package/src/app/api/user/runtime-deployments/route.ts +94 -0
- package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
- package/src/app/api/user/settings/route.ts +18 -1
- package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
- package/src/app/api/user/trading-accounts/route.ts +134 -0
- package/src/app/components/Backtest/CompareList/index.tsx +1 -1
- package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
- package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
- package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
- package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
- package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
- package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
- package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
- package/src/app/components/Backtest/TestCard/Root/index.tsx +1 -1
- package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
- package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
- package/src/app/components/Backtest/TestCard/index.ts +6 -2
- package/src/app/components/Backtest/TestList/index.tsx +4 -9
- package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
- package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
- package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
- package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
- package/src/app/components/Dashboard/KlineChart/index.tsx +16 -6
- package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
- package/src/app/components/Shared/AppShell.tsx +1 -1
- package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
- package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
- package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
- package/src/app/components/Shared/Filters/Backtest/index.tsx +33 -48
- package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
- package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
- package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
- package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
- package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
- package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
- package/src/app/components/Shared/Filters/index.ts +2 -0
- package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
- package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
- package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
- package/src/app/components/Shared/Sidebar/index.tsx +17 -3
- package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
- package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
- package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
- package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
- package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
- package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
- package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
- package/src/app/components/UI/Segment/index.tsx +8 -1
- package/src/app/components/UI/Select/index.tsx +24 -13
- package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
- package/src/app/layout.tsx +1 -1
- package/src/app/lib/backtestJobs.ts +792 -0
- package/src/app/lib/cardPageLayout.ts +1 -0
- package/src/app/lib/connectorCreator.ts +28 -0
- package/src/app/lib/currentUser.ts +1 -1
- package/src/app/lib/errorMessage.ts +34 -0
- package/src/app/lib/marketDefaults.ts +12 -0
- package/src/app/lib/marketRoutes.ts +58 -0
- package/src/app/lib/runtimeStrategies.ts +1376 -0
- package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
- package/src/app/provider.tsx +1 -1
- package/src/app/routes/backtest/page.tsx +1051 -304
- package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +35 -15
- package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
- package/src/app/routes/dashboard/page.tsx +15 -3
- package/src/app/routes/derivatives/page.tsx +1222 -165
- package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
- package/src/app/routes/strategies/[mode]/page.tsx +20 -0
- package/src/app/routes/strategies/page.tsx +7 -0
- package/src/app/store/ai.ts +1 -1
- package/src/app/store/data.ts +73 -17
- package/src/app/store/filters.ts +1 -0
- package/src/app/store/marketKlineStream.ts +87 -0
- package/src/app/store/tests.ts +1 -1
- package/src/app/store/tickers.ts +59 -22
- package/tsconfig.json +17 -12
- package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
- package/src/app/routes/backtest/[test]/page.tsx +0 -33
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
deleteRuntimeDeployment,
|
|
4
|
+
getRuntimeDeployment,
|
|
5
|
+
} from '@tradejs/infra/tradingAccounts';
|
|
6
|
+
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
7
|
+
|
|
8
|
+
export const DELETE = async (
|
|
9
|
+
_request: Request,
|
|
10
|
+
{ params }: { params: Promise<{ deploymentId: string }> },
|
|
11
|
+
) => {
|
|
12
|
+
const userName = await getCurrentUserName();
|
|
13
|
+
if (!userName) {
|
|
14
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
15
|
+
}
|
|
16
|
+
const { deploymentId } = await params;
|
|
17
|
+
if (!(await getRuntimeDeployment(userName, deploymentId))) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: 'Deployment not found' },
|
|
20
|
+
{ status: 404 },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
await deleteRuntimeDeployment(userName, deploymentId);
|
|
24
|
+
return NextResponse.json({ deleted: true });
|
|
25
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
getTradingAccount,
|
|
4
|
+
getRuntimeDeploymentHeartbeat,
|
|
5
|
+
listRuntimeDeployments,
|
|
6
|
+
saveRuntimeDeployment,
|
|
7
|
+
} from '@tradejs/infra/tradingAccounts';
|
|
8
|
+
import { isMarketUniverse, type RuntimeDeployment } from '@tradejs/types';
|
|
9
|
+
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
10
|
+
|
|
11
|
+
export const dynamic = 'force-dynamic';
|
|
12
|
+
|
|
13
|
+
export const GET = async () => {
|
|
14
|
+
const userName = await getCurrentUserName();
|
|
15
|
+
if (!userName) {
|
|
16
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
17
|
+
}
|
|
18
|
+
const deployments = await listRuntimeDeployments(userName);
|
|
19
|
+
const withHeartbeats = await Promise.all(
|
|
20
|
+
deployments.map(async (deployment) => ({
|
|
21
|
+
...deployment,
|
|
22
|
+
heartbeat: await getRuntimeDeploymentHeartbeat(userName, deployment.id),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
return NextResponse.json({ deployments: withHeartbeats });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const POST = async (request: NextRequest) => {
|
|
29
|
+
const userName = await getCurrentUserName();
|
|
30
|
+
if (!userName) {
|
|
31
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const body = (await request.json()) as Partial<RuntimeDeployment>;
|
|
35
|
+
const universe = body.universe;
|
|
36
|
+
if (
|
|
37
|
+
!body.id ||
|
|
38
|
+
!body.label ||
|
|
39
|
+
!body.connectorName ||
|
|
40
|
+
!body.provider ||
|
|
41
|
+
!body.accountId ||
|
|
42
|
+
!body.interval ||
|
|
43
|
+
!isMarketUniverse(universe) ||
|
|
44
|
+
!Array.isArray(body.strategies) ||
|
|
45
|
+
!body.strategies.length ||
|
|
46
|
+
body.strategies.some(
|
|
47
|
+
(strategy) =>
|
|
48
|
+
!String(strategy.strategyName ?? '').trim() ||
|
|
49
|
+
!String(strategy.policyProfileId ?? '').trim(),
|
|
50
|
+
)
|
|
51
|
+
) {
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{ error: 'Invalid runtime deployment' },
|
|
54
|
+
{ status: 400 },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const account = await getTradingAccount(userName, body.accountId);
|
|
58
|
+
if (
|
|
59
|
+
!account ||
|
|
60
|
+
!account.enabled ||
|
|
61
|
+
account.provider !== body.provider.toLowerCase()
|
|
62
|
+
) {
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{ error: 'Deployment trading account is unavailable' },
|
|
65
|
+
{ status: 400 },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!account.universes.includes(universe)) {
|
|
69
|
+
return NextResponse.json(
|
|
70
|
+
{ error: `Account ${account.id} does not support ${universe}` },
|
|
71
|
+
{ status: 400 },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const deployment = await saveRuntimeDeployment(userName, {
|
|
75
|
+
id: body.id,
|
|
76
|
+
label: body.label,
|
|
77
|
+
connectorName: body.connectorName,
|
|
78
|
+
provider: body.provider,
|
|
79
|
+
accountId: body.accountId,
|
|
80
|
+
universe,
|
|
81
|
+
interval: String(body.interval),
|
|
82
|
+
enabled: body.enabled !== false,
|
|
83
|
+
strategies: body.strategies,
|
|
84
|
+
assetClasses: body.assetClasses,
|
|
85
|
+
tickers: body.tickers,
|
|
86
|
+
});
|
|
87
|
+
return NextResponse.json({ deployment });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
91
|
+
{ status: 400 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getAvailableStrategyNames } from '@tradejs/node/strategies';
|
|
3
|
+
import {
|
|
4
|
+
listTradingAccounts,
|
|
5
|
+
resolveTradingAccount,
|
|
6
|
+
} from '@tradejs/infra/tradingAccounts';
|
|
7
|
+
import { getData, getKeys, redisKeys, setData } from '@tradejs/infra/redis';
|
|
8
|
+
import type { Interval, MarketUniverse, StrategyConfig } from '@tradejs/types';
|
|
9
|
+
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
10
|
+
import { resolveStrategyConfigIdentityByKey } from '#app/lib/runtimeStrategies';
|
|
11
|
+
|
|
12
|
+
export const dynamic = 'force-dynamic';
|
|
13
|
+
|
|
14
|
+
const projectRoot =
|
|
15
|
+
String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
|
|
16
|
+
const INTERVALS = new Set([
|
|
17
|
+
'1',
|
|
18
|
+
'3',
|
|
19
|
+
'5',
|
|
20
|
+
'15',
|
|
21
|
+
'30',
|
|
22
|
+
'60',
|
|
23
|
+
'120',
|
|
24
|
+
'240',
|
|
25
|
+
'360',
|
|
26
|
+
'720',
|
|
27
|
+
'1440',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
type StoredRuntimeConfig = {
|
|
31
|
+
key: string;
|
|
32
|
+
strategyName: string;
|
|
33
|
+
configId: string;
|
|
34
|
+
config: StrategyConfig;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const loadConfigs = async (
|
|
38
|
+
userName: string,
|
|
39
|
+
): Promise<StoredRuntimeConfig[]> => {
|
|
40
|
+
const keys = await getKeys(`${redisKeys.strategies(userName)}:`);
|
|
41
|
+
const rows = await Promise.all(
|
|
42
|
+
keys.map(async (key): Promise<StoredRuntimeConfig | null> => {
|
|
43
|
+
const identity = resolveStrategyConfigIdentityByKey(userName, key);
|
|
44
|
+
if (!identity) return null;
|
|
45
|
+
const config = (await getData(key, null)) as StrategyConfig | null;
|
|
46
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return { key, ...identity, config };
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
return rows.filter((row): row is StoredRuntimeConfig => row != null);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const normalizeConfigId = (value: unknown) => {
|
|
56
|
+
const configId = String(value ?? '').trim();
|
|
57
|
+
if (!configId) throw new Error('Config id is required');
|
|
58
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(configId)) {
|
|
59
|
+
throw new Error('Config id may contain only letters, numbers, _ and -');
|
|
60
|
+
}
|
|
61
|
+
if (configId === 'results')
|
|
62
|
+
throw new Error('Config id "results" is reserved');
|
|
63
|
+
return configId;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const normalizeUniverse = (value: unknown): MarketUniverse =>
|
|
67
|
+
value === 'tradfi' ? 'tradfi' : 'crypto';
|
|
68
|
+
|
|
69
|
+
const normalizeInterval = (value: unknown): Interval => {
|
|
70
|
+
const interval = String(value ?? '15');
|
|
71
|
+
if (!INTERVALS.has(interval))
|
|
72
|
+
throw new Error(`Unsupported timeframe: ${interval}`);
|
|
73
|
+
return interval as Interval;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const normalizeAccountId = (value: unknown) => {
|
|
77
|
+
const accountId = String(value ?? '').trim();
|
|
78
|
+
return accountId || undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const resolveEffectiveAccountId = async ({
|
|
82
|
+
userName,
|
|
83
|
+
config,
|
|
84
|
+
}: {
|
|
85
|
+
userName: string;
|
|
86
|
+
config: StrategyConfig;
|
|
87
|
+
}) => {
|
|
88
|
+
const universe = normalizeUniverse(config.UNIVERSE);
|
|
89
|
+
const account = await resolveTradingAccount({
|
|
90
|
+
userName,
|
|
91
|
+
accountId: normalizeAccountId(config.ACCOUNT_ID),
|
|
92
|
+
provider: 'bybit',
|
|
93
|
+
universe,
|
|
94
|
+
});
|
|
95
|
+
return account?.id ?? null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const assertNoEnabledAccountConflict = async ({
|
|
99
|
+
userName,
|
|
100
|
+
strategyName,
|
|
101
|
+
configId,
|
|
102
|
+
config,
|
|
103
|
+
existingConfigs,
|
|
104
|
+
}: {
|
|
105
|
+
userName: string;
|
|
106
|
+
strategyName: string;
|
|
107
|
+
configId: string;
|
|
108
|
+
config: StrategyConfig;
|
|
109
|
+
existingConfigs: StoredRuntimeConfig[];
|
|
110
|
+
}) => {
|
|
111
|
+
if (config.ENABLE === false) return;
|
|
112
|
+
const accountId = await resolveEffectiveAccountId({ userName, config });
|
|
113
|
+
if (!accountId) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`No enabled Bybit account supports ${normalizeUniverse(config.UNIVERSE)}. Connect an account or save this config as disabled.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
for (const candidate of existingConfigs) {
|
|
119
|
+
if (
|
|
120
|
+
candidate.strategyName !== strategyName ||
|
|
121
|
+
candidate.configId === configId ||
|
|
122
|
+
candidate.config.ENABLE === false
|
|
123
|
+
) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const candidateAccountId = await resolveEffectiveAccountId({
|
|
127
|
+
userName,
|
|
128
|
+
config: candidate.config,
|
|
129
|
+
});
|
|
130
|
+
if (candidateAccountId === accountId) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`${strategyName} config "${candidate.configId}" already uses account "${accountId}". One strategy can run only once per account.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const toResponseConfig = async (
|
|
139
|
+
userName: string,
|
|
140
|
+
row: StoredRuntimeConfig,
|
|
141
|
+
) => ({
|
|
142
|
+
strategyName: row.strategyName,
|
|
143
|
+
configId: row.configId,
|
|
144
|
+
interval: normalizeInterval(row.config.INTERVAL),
|
|
145
|
+
universe: normalizeUniverse(row.config.UNIVERSE),
|
|
146
|
+
accountId: normalizeAccountId(row.config.ACCOUNT_ID) ?? null,
|
|
147
|
+
effectiveAccountId: await resolveEffectiveAccountId({
|
|
148
|
+
userName,
|
|
149
|
+
config: row.config,
|
|
150
|
+
}).catch(() => null),
|
|
151
|
+
enabled: row.config.ENABLE !== false,
|
|
152
|
+
config: row.config,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const GET = async () => {
|
|
156
|
+
const userName = await getCurrentUserName();
|
|
157
|
+
if (!userName)
|
|
158
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
159
|
+
const [configs, strategyNames, accounts] = await Promise.all([
|
|
160
|
+
loadConfigs(userName),
|
|
161
|
+
getAvailableStrategyNames(projectRoot),
|
|
162
|
+
listTradingAccounts(userName),
|
|
163
|
+
]);
|
|
164
|
+
return NextResponse.json({
|
|
165
|
+
configs: await Promise.all(
|
|
166
|
+
configs.map((row) => toResponseConfig(userName, row)),
|
|
167
|
+
),
|
|
168
|
+
strategyNames,
|
|
169
|
+
accounts: accounts.map(
|
|
170
|
+
({ apiKey: _apiKey, apiSecret: _apiSecret, ...account }) => account,
|
|
171
|
+
),
|
|
172
|
+
intervals: [...INTERVALS],
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const save = async (request: NextRequest, editing: boolean) => {
|
|
177
|
+
const userName = await getCurrentUserName();
|
|
178
|
+
if (!userName)
|
|
179
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
180
|
+
try {
|
|
181
|
+
const body = (await request.json()) as Record<string, unknown>;
|
|
182
|
+
const strategyName = String(body.strategyName ?? '').trim();
|
|
183
|
+
const configId = normalizeConfigId(body.configId);
|
|
184
|
+
const availableStrategies = await getAvailableStrategyNames(projectRoot);
|
|
185
|
+
if (!strategyName || !availableStrategies.includes(strategyName)) {
|
|
186
|
+
throw new Error(`Unknown strategy: ${strategyName || '(empty)'}`);
|
|
187
|
+
}
|
|
188
|
+
const existingConfigs = await loadConfigs(userName);
|
|
189
|
+
const existing = existingConfigs.find(
|
|
190
|
+
(row) => row.strategyName === strategyName && row.configId === configId,
|
|
191
|
+
);
|
|
192
|
+
if (editing && !existing)
|
|
193
|
+
throw new Error('Runtime strategy config not found');
|
|
194
|
+
if (!editing && existing)
|
|
195
|
+
throw new Error('Runtime strategy config already exists');
|
|
196
|
+
const parameters = body.parameters;
|
|
197
|
+
if (
|
|
198
|
+
!parameters ||
|
|
199
|
+
typeof parameters !== 'object' ||
|
|
200
|
+
Array.isArray(parameters)
|
|
201
|
+
) {
|
|
202
|
+
throw new Error('Strategy parameters must be a JSON object');
|
|
203
|
+
}
|
|
204
|
+
const interval = normalizeInterval(body.interval);
|
|
205
|
+
const universe = normalizeUniverse(body.universe);
|
|
206
|
+
const accountId = normalizeAccountId(body.accountId);
|
|
207
|
+
const config: StrategyConfig = {
|
|
208
|
+
...(parameters as StrategyConfig),
|
|
209
|
+
ENABLE: body.enabled !== false,
|
|
210
|
+
INTERVAL: interval,
|
|
211
|
+
UNIVERSE: universe,
|
|
212
|
+
};
|
|
213
|
+
if (accountId) config.ACCOUNT_ID = accountId;
|
|
214
|
+
else delete config.ACCOUNT_ID;
|
|
215
|
+
|
|
216
|
+
await assertNoEnabledAccountConflict({
|
|
217
|
+
userName,
|
|
218
|
+
strategyName,
|
|
219
|
+
configId,
|
|
220
|
+
config,
|
|
221
|
+
existingConfigs,
|
|
222
|
+
});
|
|
223
|
+
const key = redisKeys.strategyConfig(userName, strategyName, configId);
|
|
224
|
+
await setData(key, config, { expire: 0 });
|
|
225
|
+
return NextResponse.json({
|
|
226
|
+
config: await toResponseConfig(userName, {
|
|
227
|
+
key,
|
|
228
|
+
strategyName,
|
|
229
|
+
configId,
|
|
230
|
+
config,
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
235
|
+
const status = message.includes('already') ? 409 : 400;
|
|
236
|
+
return NextResponse.json({ error: message }, { status });
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const POST = (request: NextRequest) => save(request, false);
|
|
241
|
+
export const PATCH = (request: NextRequest) => save(request, true);
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type UserRecord,
|
|
11
11
|
type UserSettings,
|
|
12
12
|
} from '@tradejs/infra/userSettings';
|
|
13
|
-
import { getCurrentUserName } from '
|
|
13
|
+
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
14
14
|
|
|
15
15
|
export const dynamic = 'force-dynamic';
|
|
16
16
|
|
|
@@ -28,6 +28,12 @@ type UpdateBody =
|
|
|
28
28
|
apiKey?: string;
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
| {
|
|
32
|
+
section: 'coinmarketcap';
|
|
33
|
+
data?: {
|
|
34
|
+
apiKey?: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
31
37
|
| {
|
|
32
38
|
section: 'ai';
|
|
33
39
|
data?: {
|
|
@@ -83,6 +89,9 @@ const toResponse = (settings: UserSettings) => ({
|
|
|
83
89
|
coinalyze: {
|
|
84
90
|
apiKey: maskSecret(settings.COINALYZE_API_KEY),
|
|
85
91
|
},
|
|
92
|
+
coinmarketcap: {
|
|
93
|
+
apiKey: maskSecret(settings.COINMARKETCAP_API_KEY),
|
|
94
|
+
},
|
|
86
95
|
ai: {
|
|
87
96
|
apiKey: maskSecret(settings.AI_API_KEY),
|
|
88
97
|
apiEndpoint: settings.AI_API_ENDPOINT,
|
|
@@ -180,6 +189,14 @@ export const PATCH = async (request: Request) => {
|
|
|
180
189
|
}
|
|
181
190
|
}
|
|
182
191
|
|
|
192
|
+
if (body.section === 'coinmarketcap') {
|
|
193
|
+
const apiKey = cleanOptionalText(body.data?.apiKey);
|
|
194
|
+
|
|
195
|
+
if (apiKey) {
|
|
196
|
+
await updateUserRecord(userName, { COINMARKETCAP_API_KEY: apiKey });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
183
200
|
if (body.section === 'ai') {
|
|
184
201
|
const currentSettings = await getUserSettings(userName);
|
|
185
202
|
const patch: Partial<UserRecord> = {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
deleteTradingAccount,
|
|
4
|
+
getTradingAccount,
|
|
5
|
+
listRuntimeDeployments,
|
|
6
|
+
} from '@tradejs/infra/tradingAccounts';
|
|
7
|
+
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
8
|
+
|
|
9
|
+
export const DELETE = async (
|
|
10
|
+
_request: Request,
|
|
11
|
+
{ params }: { params: Promise<{ accountId: string }> },
|
|
12
|
+
) => {
|
|
13
|
+
const userName = await getCurrentUserName();
|
|
14
|
+
if (!userName) {
|
|
15
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
|
+
}
|
|
17
|
+
const { accountId } = await params;
|
|
18
|
+
const account = await getTradingAccount(userName, accountId);
|
|
19
|
+
if (!account) {
|
|
20
|
+
return NextResponse.json({ error: 'Account not found' }, { status: 404 });
|
|
21
|
+
}
|
|
22
|
+
const referencedBy = (await listRuntimeDeployments(userName)).filter(
|
|
23
|
+
(deployment) => deployment.accountId === account.id,
|
|
24
|
+
);
|
|
25
|
+
if (referencedBy.length) {
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{
|
|
28
|
+
error: `Account is used by deployments: ${referencedBy.map(({ id }) => id).join(', ')}`,
|
|
29
|
+
},
|
|
30
|
+
{ status: 409 },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
await deleteTradingAccount(userName, accountId);
|
|
34
|
+
return NextResponse.json({ deleted: true });
|
|
35
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
getTradingAccount,
|
|
4
|
+
listTradingAccounts,
|
|
5
|
+
saveTradingAccount,
|
|
6
|
+
} from '@tradejs/infra/tradingAccounts';
|
|
7
|
+
import { getUserSettings } from '@tradejs/infra/userSettings';
|
|
8
|
+
import { isMarketUniverse, type TradingAccountRef } from '@tradejs/types';
|
|
9
|
+
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
10
|
+
|
|
11
|
+
export const dynamic = 'force-dynamic';
|
|
12
|
+
|
|
13
|
+
const maskSecret = (value?: string) => {
|
|
14
|
+
const trimmed = String(value ?? '').trim();
|
|
15
|
+
return trimmed ? `${'*'.repeat(12)}${trimmed.slice(-4)}` : '';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const toPublicAccount = (account: TradingAccountRef) => {
|
|
19
|
+
const { apiKey, apiSecret, ...safe } = account;
|
|
20
|
+
return {
|
|
21
|
+
...safe,
|
|
22
|
+
hasApiKey: Boolean(apiKey),
|
|
23
|
+
hasApiSecret: Boolean(apiSecret),
|
|
24
|
+
maskedApiKey: maskSecret(apiKey),
|
|
25
|
+
maskedApiSecret: maskSecret(apiSecret),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
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
|
+
export const GET = async () => {
|
|
67
|
+
const userName = await getCurrentUserName();
|
|
68
|
+
if (!userName) {
|
|
69
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
70
|
+
}
|
|
71
|
+
const accounts = await migrateLegacyBybitAccount(
|
|
72
|
+
userName,
|
|
73
|
+
await listTradingAccounts(userName),
|
|
74
|
+
);
|
|
75
|
+
return NextResponse.json({ accounts: accounts.map(toPublicAccount) });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const POST = async (request: NextRequest) => {
|
|
79
|
+
const userName = await getCurrentUserName();
|
|
80
|
+
if (!userName) {
|
|
81
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const body = (await request.json()) as Partial<TradingAccountRef>;
|
|
85
|
+
const id = String(body.id ?? '').trim();
|
|
86
|
+
const label = String(body.label ?? '').trim();
|
|
87
|
+
const provider = String(body.provider ?? '')
|
|
88
|
+
.trim()
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
const universes = Array.isArray(body.universes)
|
|
91
|
+
? body.universes.filter(isMarketUniverse)
|
|
92
|
+
: [];
|
|
93
|
+
if (!id || !label || !provider || !universes.length) {
|
|
94
|
+
return NextResponse.json(
|
|
95
|
+
{ error: 'id, label, provider and universes are required' },
|
|
96
|
+
{ status: 400 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const existing = await getTradingAccount(userName, id);
|
|
100
|
+
const apiKey =
|
|
101
|
+
typeof body.apiKey === 'string' && body.apiKey.trim()
|
|
102
|
+
? body.apiKey.trim()
|
|
103
|
+
: existing?.apiKey;
|
|
104
|
+
const apiSecret =
|
|
105
|
+
typeof body.apiSecret === 'string' && body.apiSecret.trim()
|
|
106
|
+
? body.apiSecret.trim()
|
|
107
|
+
: existing?.apiSecret;
|
|
108
|
+
if (!apiKey || !apiSecret) {
|
|
109
|
+
return NextResponse.json(
|
|
110
|
+
{ error: 'apiKey and apiSecret are required for a new account' },
|
|
111
|
+
{ status: 400 },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const account = await saveTradingAccount(userName, {
|
|
115
|
+
...existing,
|
|
116
|
+
id,
|
|
117
|
+
label,
|
|
118
|
+
provider,
|
|
119
|
+
enabled: body.enabled !== false,
|
|
120
|
+
isDefault: Boolean(body.isDefault),
|
|
121
|
+
universes,
|
|
122
|
+
environment: body.environment === 'testnet' ? 'testnet' : 'mainnet',
|
|
123
|
+
readOnly: Boolean(body.readOnly),
|
|
124
|
+
apiKey,
|
|
125
|
+
apiSecret,
|
|
126
|
+
});
|
|
127
|
+
return NextResponse.json({ account: toPublicAccount(account) });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return NextResponse.json(
|
|
130
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
131
|
+
{ status: 400 },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { For, Tag, HStack, Text } from '@chakra-ui/react';
|
|
2
2
|
import _ from 'lodash';
|
|
3
|
-
import { useTestsCompare } from '
|
|
3
|
+
import { useTestsCompare } from '#store';
|
|
4
4
|
|
|
5
5
|
export const CompareList = () => {
|
|
6
6
|
const { compareList, onChangeCompare } = useTestsCompare();
|