@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.
- package/bin/tradejs-app.mjs +129 -20
- package/package.json +13 -12
- package/public/auth-bg.jpg +0 -0
- package/public/next.svg +1 -0
- package/public/og-image-source.svg +91 -0
- package/public/og-image.png +0 -0
- package/public/vercel.svg +1 -0
- package/src/app/api/ai/route.ts +84 -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 +357 -29
- package/src/app/api/scanner/[provider]/route.ts +7 -1
- package/src/app/api/scanner/route.ts +7 -1
- package/src/app/api/signal/[symbol]/[signalId]/route.ts +6 -0
- package/src/app/api/user/settings/route.ts +244 -0
- 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 +948 -0
- package/src/app/components/Shared/Sidebar/index.tsx +13 -9
- 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/globals.css +11 -0
- package/src/app/layout.tsx +50 -11
- package/src/app/lib/currentUser.ts +27 -0
- package/src/app/lib/klineWindow.ts +17 -0
- package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +10 -2
- package/src/app/routes/signin/page.tsx +11 -2
- package/src/app/store/ai.ts +174 -0
- package/src/app/store/data.ts +219 -88
- 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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
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';
|
|
6
|
+
import {
|
|
7
|
+
getUserRecord,
|
|
8
|
+
getUserSettings,
|
|
9
|
+
updateUserRecord,
|
|
10
|
+
type UserRecord,
|
|
11
|
+
type UserSettings,
|
|
12
|
+
} from '@tradejs/infra/userSettings';
|
|
13
|
+
import { getCurrentUserName } from '@app/lib/currentUser';
|
|
14
|
+
|
|
15
|
+
export const dynamic = 'force-dynamic';
|
|
16
|
+
|
|
17
|
+
type UpdateBody =
|
|
18
|
+
| {
|
|
19
|
+
section: 'bybit';
|
|
20
|
+
data?: {
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
apiSecret?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
section: 'coinalyze';
|
|
27
|
+
data?: {
|
|
28
|
+
apiKey?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
section: 'ai';
|
|
33
|
+
data?: {
|
|
34
|
+
apiKey?: string;
|
|
35
|
+
apiEndpoint?: string;
|
|
36
|
+
model?: string;
|
|
37
|
+
responseLanguage?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
section: 'telegram';
|
|
42
|
+
data?: {
|
|
43
|
+
botToken?: string;
|
|
44
|
+
chatId?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
section: 'password';
|
|
49
|
+
data?: {
|
|
50
|
+
password?: string;
|
|
51
|
+
confirmPassword?: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const cleanText = (value: unknown): string =>
|
|
56
|
+
typeof value === 'string' ? value.trim() : '';
|
|
57
|
+
|
|
58
|
+
const cleanOptionalText = (value: unknown): string | undefined => {
|
|
59
|
+
if (typeof value !== 'string') {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
return trimmed ? trimmed : undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const maskSecret = (value: string) => {
|
|
68
|
+
const trimmed = cleanText(value);
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `${'*'.repeat(12)}${trimmed.slice(-4) || trimmed}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const toResponse = (settings: UserSettings) => ({
|
|
77
|
+
userName: settings.userName,
|
|
78
|
+
settings: {
|
|
79
|
+
bybit: {
|
|
80
|
+
apiKey: maskSecret(settings.BYBIT_API_KEY),
|
|
81
|
+
apiSecret: maskSecret(settings.BYBIT_API_SECRET),
|
|
82
|
+
},
|
|
83
|
+
coinalyze: {
|
|
84
|
+
apiKey: maskSecret(settings.COINALYZE_API_KEY),
|
|
85
|
+
},
|
|
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,
|
|
91
|
+
},
|
|
92
|
+
telegram: {
|
|
93
|
+
botToken: maskSecret(settings.TG_BOT_TOKEN),
|
|
94
|
+
chatId: settings.TG_CHAT_ID,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const hasKeys = (patch: Partial<UserRecord>) => Object.keys(patch).length > 0;
|
|
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
|
+
|
|
110
|
+
export const GET = async () => {
|
|
111
|
+
const userName = await getCurrentUserName();
|
|
112
|
+
if (!userName) {
|
|
113
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await removeLegacyPasswordlessToken(userName);
|
|
117
|
+
const settings = await getUserSettings(userName);
|
|
118
|
+
return NextResponse.json(toResponse(settings));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const PATCH = async (request: Request) => {
|
|
122
|
+
const userName = await getCurrentUserName();
|
|
123
|
+
if (!userName) {
|
|
124
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await removeLegacyPasswordlessToken(userName);
|
|
128
|
+
const body = (await request.json()) as UpdateBody | null;
|
|
129
|
+
if (!body || typeof body !== 'object' || !('section' in body)) {
|
|
130
|
+
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (body.section === 'password') {
|
|
134
|
+
const password = String(body.data?.password || '');
|
|
135
|
+
const confirmPassword = String(body.data?.confirmPassword || '');
|
|
136
|
+
|
|
137
|
+
if (!password) {
|
|
138
|
+
return NextResponse.json(
|
|
139
|
+
{ error: 'Password is required' },
|
|
140
|
+
{ status: 400 },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (password !== confirmPassword) {
|
|
145
|
+
return NextResponse.json(
|
|
146
|
+
{ error: 'Password confirmation does not match' },
|
|
147
|
+
{ status: 400 },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
152
|
+
await updateUserRecord(userName, { passwordHash });
|
|
153
|
+
const settings = await getUserSettings(userName);
|
|
154
|
+
return NextResponse.json(toResponse(settings));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (body.section === 'bybit') {
|
|
158
|
+
const patch: Partial<UserRecord> = {};
|
|
159
|
+
const apiKey = cleanOptionalText(body.data?.apiKey);
|
|
160
|
+
const apiSecret = cleanOptionalText(body.data?.apiSecret);
|
|
161
|
+
|
|
162
|
+
if (apiKey) {
|
|
163
|
+
patch.BYBIT_API_KEY = apiKey;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (apiSecret) {
|
|
167
|
+
patch.BYBIT_API_SECRET = apiSecret;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (hasKeys(patch)) {
|
|
171
|
+
await updateUserRecord(userName, patch);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (body.section === 'coinalyze') {
|
|
176
|
+
const apiKey = cleanOptionalText(body.data?.apiKey);
|
|
177
|
+
|
|
178
|
+
if (apiKey) {
|
|
179
|
+
await updateUserRecord(userName, { COINALYZE_API_KEY: apiKey });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (body.section === 'ai') {
|
|
184
|
+
const currentSettings = await getUserSettings(userName);
|
|
185
|
+
const patch: Partial<UserRecord> = {};
|
|
186
|
+
const apiKey = cleanOptionalText(body.data?.apiKey);
|
|
187
|
+
const apiEndpoint = normalizeAiEndpoint(body.data?.apiEndpoint);
|
|
188
|
+
const effectiveEndpoint = apiEndpoint || currentSettings.AI_API_ENDPOINT;
|
|
189
|
+
const responseLanguage = normalizeAiResponseLanguage(
|
|
190
|
+
body.data?.responseLanguage,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (apiKey) {
|
|
194
|
+
patch.AI_API_KEY = apiKey;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (body.data && 'apiEndpoint' in body.data && !apiEndpoint) {
|
|
198
|
+
return NextResponse.json(
|
|
199
|
+
{ error: 'Invalid AI API endpoint URL' },
|
|
200
|
+
{ status: 400 },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (apiEndpoint) {
|
|
205
|
+
patch.AI_API_ENDPOINT = apiEndpoint;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (
|
|
209
|
+
body.data &&
|
|
210
|
+
('apiEndpoint' in body.data || 'model' in body.data) &&
|
|
211
|
+
effectiveEndpoint
|
|
212
|
+
) {
|
|
213
|
+
patch.AI_MODEL = normalizeAiModel(body.data?.model, effectiveEndpoint);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (body.data && 'responseLanguage' in body.data) {
|
|
217
|
+
patch.AI_RESPONSE_LANGUAGE = responseLanguage;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (hasKeys(patch)) {
|
|
221
|
+
await updateUserRecord(userName, patch);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (body.section === 'telegram') {
|
|
226
|
+
const patch: Partial<UserRecord> = {};
|
|
227
|
+
const botToken = cleanOptionalText(body.data?.botToken);
|
|
228
|
+
|
|
229
|
+
if (botToken) {
|
|
230
|
+
patch.TG_BOT_TOKEN = botToken;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (body.data && 'chatId' in body.data) {
|
|
234
|
+
patch.TG_CHAT_ID = cleanText(body.data.chatId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (hasKeys(patch)) {
|
|
238
|
+
await updateUserRecord(userName, patch);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const settings = await getUserSettings(userName);
|
|
243
|
+
return NextResponse.json(toResponse(settings));
|
|
244
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import {
|
|
5
5
|
Button,
|
|
6
6
|
CloseButton,
|
|
@@ -12,74 +12,42 @@ import {
|
|
|
12
12
|
HStack,
|
|
13
13
|
Stack,
|
|
14
14
|
Text,
|
|
15
|
+
Alert,
|
|
15
16
|
SkeletonCircle,
|
|
16
17
|
SkeletonText,
|
|
17
18
|
} from '@chakra-ui/react';
|
|
18
|
-
import { AIChatMessage, AIChatHistory } from '@tradejs/types';
|
|
19
19
|
import { GiArtificialHive } from 'react-icons/gi';
|
|
20
|
-
import { useFilters } from '@store';
|
|
21
|
-
import { sendMessage, getHistory } from '@actions/ai';
|
|
20
|
+
import { useAiChatStore, useFilters } from '@store';
|
|
22
21
|
import { Message } from './Message';
|
|
23
22
|
|
|
24
23
|
export const AiDrawer = () => {
|
|
25
24
|
const [open, setOpen] = useState(false);
|
|
26
|
-
const [loading, setLoading] = useState(true);
|
|
27
25
|
const [input, setInput] = useState('');
|
|
28
|
-
const [messages, setMessages] = useState<AIChatHistory>([]);
|
|
29
26
|
const { filters } = useFilters();
|
|
30
|
-
|
|
31
|
-
const loadHistory =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
27
|
+
const getChat = useAiChatStore((s) => s.getChat);
|
|
28
|
+
const loadHistory = useAiChatStore((s) => s.loadHistory);
|
|
29
|
+
const sendPrompt = useAiChatStore((s) => s.sendPrompt);
|
|
30
|
+
const sendQuickCommand = useAiChatStore((s) => s.sendQuickCommand);
|
|
31
|
+
const chat = getChat(filters.symbol);
|
|
32
|
+
const { loading, sending, error, messages } = chat;
|
|
33
|
+
const isBusy = loading || sending;
|
|
34
|
+
const canSend = useMemo(
|
|
35
|
+
() => input.trim().length > 0 && !sending,
|
|
36
|
+
[input, sending],
|
|
37
|
+
);
|
|
40
38
|
|
|
41
39
|
useEffect(() => {
|
|
42
|
-
void loadHistory();
|
|
43
|
-
}, [loadHistory]);
|
|
40
|
+
void loadHistory(filters.symbol);
|
|
41
|
+
}, [filters.symbol, loadHistory]);
|
|
44
42
|
|
|
45
43
|
const handleSend = async () => {
|
|
46
44
|
if (!input.trim()) return;
|
|
47
|
-
|
|
48
|
-
const message = {
|
|
49
|
-
from: 'user',
|
|
50
|
-
text: input,
|
|
51
|
-
command: 'prompt',
|
|
52
|
-
} as AIChatMessage;
|
|
53
|
-
|
|
54
|
-
setMessages((state) => [...state, message]);
|
|
55
|
-
|
|
56
|
-
const response = await sendMessage({ message, filters });
|
|
57
|
-
|
|
58
|
-
setMessages((state) => [...state, response]);
|
|
59
|
-
|
|
45
|
+
await sendPrompt(filters, input);
|
|
60
46
|
setInput('');
|
|
61
47
|
};
|
|
62
48
|
|
|
63
49
|
const handleQuick = async (command: string) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (command === '/line') {
|
|
67
|
-
message = {
|
|
68
|
-
from: 'user',
|
|
69
|
-
text: 'Какие наклонные линии можно построить на данном графике',
|
|
70
|
-
command,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (!message) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
setMessages((state) => [...state, message as AIChatMessage]);
|
|
79
|
-
|
|
80
|
-
const response = await sendMessage({ message, filters });
|
|
81
|
-
|
|
82
|
-
setMessages((state) => [...state, response]);
|
|
50
|
+
await sendQuickCommand(filters, command);
|
|
83
51
|
};
|
|
84
52
|
|
|
85
53
|
return (
|
|
@@ -106,6 +74,15 @@ export const AiDrawer = () => {
|
|
|
106
74
|
</Drawer.Header>
|
|
107
75
|
|
|
108
76
|
<Drawer.Body overflowY="auto" flex="1">
|
|
77
|
+
{error ? (
|
|
78
|
+
<Alert.Root status="error" mb={4}>
|
|
79
|
+
<Alert.Indicator />
|
|
80
|
+
<Alert.Content>
|
|
81
|
+
<Alert.Title>AI chat error</Alert.Title>
|
|
82
|
+
<Alert.Description>{error}</Alert.Description>
|
|
83
|
+
</Alert.Content>
|
|
84
|
+
</Alert.Root>
|
|
85
|
+
) : null}
|
|
109
86
|
{loading ? (
|
|
110
87
|
<Stack gap="4" maxW="xs">
|
|
111
88
|
<HStack width="full">
|
|
@@ -128,6 +105,7 @@ export const AiDrawer = () => {
|
|
|
128
105
|
<Button
|
|
129
106
|
size="sm"
|
|
130
107
|
variant="outline"
|
|
108
|
+
disabled={isBusy}
|
|
131
109
|
onClick={() => handleQuick('/line')}
|
|
132
110
|
>
|
|
133
111
|
/line
|
|
@@ -135,6 +113,7 @@ export const AiDrawer = () => {
|
|
|
135
113
|
<Button
|
|
136
114
|
size="sm"
|
|
137
115
|
variant="outline"
|
|
116
|
+
disabled={isBusy}
|
|
138
117
|
onClick={() => handleQuick('/analyze')}
|
|
139
118
|
>
|
|
140
119
|
/analyze
|
|
@@ -148,10 +127,18 @@ export const AiDrawer = () => {
|
|
|
148
127
|
rows={3}
|
|
149
128
|
maxH="15lh"
|
|
150
129
|
value={input}
|
|
130
|
+
disabled={sending}
|
|
151
131
|
onChange={(e) => setInput(e.target.value)}
|
|
152
132
|
/>
|
|
153
133
|
|
|
154
|
-
<Button
|
|
134
|
+
<Button
|
|
135
|
+
mt={2}
|
|
136
|
+
size={'sm'}
|
|
137
|
+
variant="subtle"
|
|
138
|
+
disabled={!canSend}
|
|
139
|
+
loading={sending}
|
|
140
|
+
onClick={handleSend}
|
|
141
|
+
>
|
|
155
142
|
Send
|
|
156
143
|
</Button>
|
|
157
144
|
</Drawer.Footer>
|
|
@@ -6,7 +6,8 @@ import { Select } from '@UI';
|
|
|
6
6
|
import { useFiltersContext } from '../context';
|
|
7
7
|
|
|
8
8
|
export const SelectBacktest = () => {
|
|
9
|
-
const { filters, backtestFiles, onChangeFilters } =
|
|
9
|
+
const { filters, backtestFiles, onChangeFilters, ensureBacktestsLoaded } =
|
|
10
|
+
useFiltersContext();
|
|
10
11
|
const STORAGE_KEY = 'backtest-strategy';
|
|
11
12
|
|
|
12
13
|
const tests = useMemo(
|
|
@@ -127,10 +128,6 @@ export const SelectBacktest = () => {
|
|
|
127
128
|
}
|
|
128
129
|
};
|
|
129
130
|
|
|
130
|
-
if (_.isEmpty(tests) || _.isEmpty(strategyItems) || !selectedStrategy) {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
131
|
const strategyTests = tests.filter(
|
|
135
132
|
(test) => test.data?.strategyName === selectedStrategy,
|
|
136
133
|
);
|
|
@@ -142,6 +139,11 @@ export const SelectBacktest = () => {
|
|
|
142
139
|
defaultValue={[selectedStrategy]}
|
|
143
140
|
value={[selectedStrategy]}
|
|
144
141
|
onChange={onChangeStrategy}
|
|
142
|
+
onOpenChange={(open) => {
|
|
143
|
+
if (open) {
|
|
144
|
+
void ensureBacktestsLoaded?.();
|
|
145
|
+
}
|
|
146
|
+
}}
|
|
145
147
|
items={strategyItems}
|
|
146
148
|
width="220px"
|
|
147
149
|
/>
|
|
@@ -150,6 +152,11 @@ export const SelectBacktest = () => {
|
|
|
150
152
|
defaultValue={[filters.backtestId || '']}
|
|
151
153
|
value={[filters.backtestId || '']}
|
|
152
154
|
onChange={onChange}
|
|
155
|
+
onOpenChange={(open) => {
|
|
156
|
+
if (open) {
|
|
157
|
+
void ensureBacktestsLoaded?.();
|
|
158
|
+
}
|
|
159
|
+
}}
|
|
153
160
|
items={[
|
|
154
161
|
{
|
|
155
162
|
label: 'Not selected',
|
|
@@ -9,6 +9,8 @@ interface RootProps {
|
|
|
9
9
|
backtestFiles: Items;
|
|
10
10
|
filters: UIFilters;
|
|
11
11
|
onChangeFilters?: OnChangeFilters;
|
|
12
|
+
ensureTickersLoaded?: () => void | Promise<unknown>;
|
|
13
|
+
ensureBacktestsLoaded?: () => void | Promise<unknown>;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export const Root = ({
|
|
@@ -16,11 +18,20 @@ export const Root = ({
|
|
|
16
18
|
tickers,
|
|
17
19
|
backtestFiles,
|
|
18
20
|
onChangeFilters,
|
|
21
|
+
ensureTickersLoaded,
|
|
22
|
+
ensureBacktestsLoaded,
|
|
19
23
|
children,
|
|
20
24
|
}: PropsWithChildren<RootProps>) => {
|
|
21
25
|
return (
|
|
22
26
|
<FiltersContext.Provider
|
|
23
|
-
value={{
|
|
27
|
+
value={{
|
|
28
|
+
filters,
|
|
29
|
+
tickers,
|
|
30
|
+
backtestFiles,
|
|
31
|
+
onChangeFilters,
|
|
32
|
+
ensureTickersLoaded,
|
|
33
|
+
ensureBacktestsLoaded,
|
|
34
|
+
}}
|
|
24
35
|
>
|
|
25
36
|
{children}
|
|
26
37
|
</FiltersContext.Provider>
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import _ from 'lodash';
|
|
4
4
|
import { SelectWithSearch } from '@UI';
|
|
5
|
-
import { SkeletonText, Stack, Show } from '@chakra-ui/react';
|
|
6
5
|
import { useFiltersContext } from '../context';
|
|
7
6
|
|
|
8
7
|
interface SelectSymbolProps {}
|
|
9
8
|
|
|
10
9
|
export const SelectSymbol = ({}: SelectSymbolProps) => {
|
|
11
|
-
const { filters, tickers, onChangeFilters } =
|
|
12
|
-
|
|
10
|
+
const { filters, tickers, onChangeFilters, ensureTickersLoaded } =
|
|
11
|
+
useFiltersContext();
|
|
13
12
|
const defaultInputValue =
|
|
14
13
|
tickers.find(({ value }) => value === filters.symbol)?.label ||
|
|
15
14
|
filters.symbol;
|
|
@@ -29,21 +28,17 @@ export const SelectSymbol = ({}: SelectSymbolProps) => {
|
|
|
29
28
|
};
|
|
30
29
|
|
|
31
30
|
return (
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
items={tickers}
|
|
45
|
-
width="240px"
|
|
46
|
-
/>
|
|
47
|
-
</Show>
|
|
31
|
+
<SelectWithSearch
|
|
32
|
+
defaultValue={[filters.symbol]}
|
|
33
|
+
defaultInputValue={defaultInputValue}
|
|
34
|
+
onChange={onChange}
|
|
35
|
+
onOpenChange={(open) => {
|
|
36
|
+
if (open) {
|
|
37
|
+
void ensureTickersLoaded?.();
|
|
38
|
+
}
|
|
39
|
+
}}
|
|
40
|
+
items={tickers}
|
|
41
|
+
width="240px"
|
|
42
|
+
/>
|
|
48
43
|
);
|
|
49
44
|
};
|
|
@@ -6,6 +6,8 @@ interface FiltersContextProps {
|
|
|
6
6
|
tickers: Items;
|
|
7
7
|
backtestFiles: Items;
|
|
8
8
|
onChangeFilters?: OnChangeFilters;
|
|
9
|
+
ensureTickersLoaded?: () => void | Promise<unknown>;
|
|
10
|
+
ensureBacktestsLoaded?: () => void | Promise<unknown>;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export const FiltersContext = createContext<FiltersContextProps>(
|