@tradejs/app 1.0.4 → 1.0.6

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.
@@ -4,6 +4,7 @@ import { Box, Flex, IconButton, VStack } from '@chakra-ui/react';
4
4
  import { useRouter, usePathname } from 'next/navigation';
5
5
  import { signOut } from 'next-auth/react';
6
6
  import { FiActivity, FiBarChart2, FiLogOut, FiPlay } from 'react-icons/fi';
7
+ import { AccountSettingsDrawer } from './AccountSettingsDrawer';
7
8
 
8
9
  export const Sidebar = () => {
9
10
  const router = useRouter();
@@ -57,15 +58,18 @@ export const Sidebar = () => {
57
58
  ))}
58
59
  </VStack>
59
60
 
60
- <IconButton
61
- aria-label="Sign out"
62
- size="md"
63
- colorPalette="teal"
64
- variant="outline"
65
- onClick={() => signOut({ callbackUrl: '/routes/signin' })}
66
- >
67
- <FiLogOut />
68
- </IconButton>
61
+ <VStack gap={2}>
62
+ <AccountSettingsDrawer />
63
+ <IconButton
64
+ aria-label="Sign out"
65
+ size="md"
66
+ colorPalette="teal"
67
+ variant="outline"
68
+ onClick={() => signOut({ callbackUrl: '/routes/signin' })}
69
+ >
70
+ <FiLogOut />
71
+ </IconButton>
72
+ </VStack>
69
73
  </Flex>
70
74
  </Box>
71
75
  );
@@ -3,3 +3,14 @@
3
3
  [data-nextjs-devtools] {
4
4
  left: 76px !important;
5
5
  } */
6
+
7
+ body {
8
+ font-family:
9
+ Inter,
10
+ ui-sans-serif,
11
+ system-ui,
12
+ -apple-system,
13
+ BlinkMacSystemFont,
14
+ 'Segoe UI',
15
+ sans-serif;
16
+ }
@@ -1,15 +1,52 @@
1
1
  import type { Metadata } from 'next';
2
- import { Inter } from 'next/font/google';
3
2
  import { ClientOnly } from '@chakra-ui/react';
4
3
  import { AppShell } from '@shared/AppShell';
5
4
  import Provider from './provider';
6
5
  import './globals.css';
7
6
 
8
- const inter = Inter({ subsets: ['latin'] });
7
+ const fallbackMetadataBase = 'http://localhost:3000';
8
+
9
+ const metadataBase = (() => {
10
+ const rawAppUrl = String(process.env.APP_URL || '').trim();
11
+
12
+ if (!rawAppUrl) {
13
+ return new URL(fallbackMetadataBase);
14
+ }
15
+
16
+ try {
17
+ return new URL(rawAppUrl);
18
+ } catch {
19
+ return new URL(fallbackMetadataBase);
20
+ }
21
+ })();
9
22
 
10
23
  export const metadata: Metadata = {
24
+ metadataBase,
11
25
  title: 'TradeJS App',
12
- description: 'Trading Strategies Framework',
26
+ description:
27
+ 'TradeJS app for dashboards, backtests, charts, derivatives, and runtime data.',
28
+ applicationName: 'TradeJS App',
29
+ openGraph: {
30
+ title: 'TradeJS App',
31
+ description:
32
+ 'TradeJS app for dashboards, backtests, charts, derivatives, and runtime data.',
33
+ type: 'website',
34
+ images: [
35
+ {
36
+ url: '/og-image.png',
37
+ width: 1200,
38
+ height: 630,
39
+ alt: 'TradeJS App',
40
+ },
41
+ ],
42
+ },
43
+ twitter: {
44
+ card: 'summary_large_image',
45
+ title: 'TradeJS App',
46
+ description:
47
+ 'Dashboards, backtests, charts, derivatives, and runtime data in one UI.',
48
+ images: ['/og-image.png'],
49
+ },
13
50
  };
14
51
 
15
52
  export default function RootLayout({
@@ -19,7 +56,7 @@ export default function RootLayout({
19
56
  }>) {
20
57
  return (
21
58
  <html lang="en" suppressHydrationWarning>
22
- <body className={inter.className}>
59
+ <body>
23
60
  <ClientOnly>
24
61
  <Provider>
25
62
  <AppShell>{children}</AppShell>
@@ -0,0 +1,27 @@
1
+ import { auth } from '@app/auth';
2
+
3
+ type SessionLike = {
4
+ user?: {
5
+ id?: string;
6
+ name?: string | null;
7
+ };
8
+ } | null;
9
+
10
+ const readSessionUserName = (session: SessionLike) => {
11
+ const fromId = session?.user?.id;
12
+ if (typeof fromId === 'string' && fromId.trim()) {
13
+ return fromId.trim();
14
+ }
15
+
16
+ const fromName = session?.user?.name;
17
+ if (typeof fromName === 'string' && fromName.trim()) {
18
+ return fromName.trim();
19
+ }
20
+
21
+ return null;
22
+ };
23
+
24
+ export const getCurrentUserName = async (): Promise<string | null> => {
25
+ const session = (await auth()) as SessionLike;
26
+ return readSessionUserName(session);
27
+ };
@@ -17,6 +17,7 @@ const Dashboard = () => {
17
17
  const hasBacktestStrategy = searchParams.has('backtestStrategy');
18
18
  const backtestId = searchParams.get('backtestId');
19
19
  const backtestStrategy = searchParams.get('backtestStrategy');
20
+ const isScreenshotMode = searchParams.get('screenshot') === '1';
20
21
 
21
22
  const parseDashboardPath = useCallback(() => {
22
23
  const parts = window.location.pathname.split('/').filter(Boolean);
@@ -89,32 +90,34 @@ const Dashboard = () => {
89
90
  <Box
90
91
  as="main"
91
92
  minH="100vh"
92
- p={4}
93
+ p={isScreenshotMode ? 0 : 4}
93
94
  bg="gray.900"
94
95
  display="flex"
95
96
  flexDirection="column"
96
97
  justifyContent="space-between"
97
98
  alignItems="flex-start"
98
99
  >
99
- <Filters.Root
100
- filters={filters}
101
- tickers={tickers}
102
- backtestFiles={tests}
103
- onChangeFilters={onChangeFilters}
104
- >
105
- <Flex mb={2} gap={4} alignItems="center" flexDirection="row">
106
- <Filters.SelectProvider />
107
- <Filters.SelectSymbol />
108
- <Filters.FavoriteIndicator />
109
- <Filters.SelectInterval />
110
- <Filters.SelectIndicator />
111
- </Flex>
112
- <Flex mb={4} gap={4} flexDirection="row">
113
- <Filters.SelectBacktest />
114
- </Flex>
115
- </Filters.Root>
100
+ {!isScreenshotMode && (
101
+ <Filters.Root
102
+ filters={filters}
103
+ tickers={tickers}
104
+ backtestFiles={tests}
105
+ onChangeFilters={onChangeFilters}
106
+ >
107
+ <Flex mb={2} gap={4} alignItems="center" flexDirection="row">
108
+ <Filters.SelectProvider />
109
+ <Filters.SelectSymbol />
110
+ <Filters.FavoriteIndicator />
111
+ <Filters.SelectInterval />
112
+ <Filters.SelectIndicator />
113
+ </Flex>
114
+ <Flex mb={4} gap={4} flexDirection="row">
115
+ <Filters.SelectBacktest />
116
+ </Flex>
117
+ </Filters.Root>
118
+ )}
116
119
  <Box position="relative" flex="1" w="full">
117
- <MainChart />
120
+ <MainChart screenshotMode={isScreenshotMode} />
118
121
  </Box>
119
122
  </Box>
120
123
  </ClientOnly>
@@ -75,8 +75,17 @@ const SigninContent = () => {
75
75
  <Text fontSize="sm" opacity={0.7} letterSpacing="0.2em">
76
76
  SIGN IN
77
77
  </Text>
78
- <Text fontSize="2xl" fontWeight="600">
79
- TradeJS
78
+ <Text
79
+ fontSize="2xl"
80
+ fontWeight="700"
81
+ letterSpacing="-0.03em"
82
+ lineHeight="1"
83
+ color="white"
84
+ >
85
+ <Box as="span">Trade</Box>
86
+ <Box as="span" color="#20c5bd">
87
+ JS
88
+ </Box>
80
89
  </Text>
81
90
  </Stack>
82
91
 
@@ -1,6 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { create } from 'zustand';
3
- import _ from 'lodash';
4
3
  import { get, set } from 'idb-keyval';
5
4
  import { useSearchParams } from 'next/navigation';
6
5
  import { KlineChartData, Interval, Filters, Provider } from '@tradejs/types';
@@ -17,8 +16,44 @@ interface DataState {
17
16
  ) => void;
18
17
  }
19
18
 
20
- const getKey = (filters: Pick<Filters, 'provider' | 'symbol' | 'interval'>) =>
21
- `${filters.provider || 'bybit'}_${filters.symbol}_${filters.interval}`;
19
+ interface DataRequest {
20
+ key: string;
21
+ provider: Provider;
22
+ symbol: string;
23
+ interval: Interval;
24
+ start: number;
25
+ end: number;
26
+ cacheOnly: boolean;
27
+ }
28
+
29
+ const MIN_CACHED_CANDLES = 2;
30
+
31
+ const getKey = (provider: Provider, symbol: string, interval: Interval) =>
32
+ `${provider}_${symbol}_${interval}`;
33
+
34
+ const getProvider = (provider?: Provider): Provider => provider || 'bybit';
35
+
36
+ const toRequest = (filters: Filters, cacheOnly: boolean): DataRequest => {
37
+ const provider = getProvider(filters.provider);
38
+
39
+ return {
40
+ key: getKey(provider, filters.symbol, filters.interval),
41
+ provider,
42
+ symbol: filters.symbol,
43
+ interval: filters.interval,
44
+ start: filters.start,
45
+ end: filters.end,
46
+ cacheOnly,
47
+ };
48
+ };
49
+
50
+ const getRequestKey = ({ key, start, end, cacheOnly }: DataRequest) =>
51
+ `${key}_${start}_${end}_${cacheOnly ? 'cache' : 'live'}`;
52
+
53
+ const hasContinuityData = (data: KlineChartData) =>
54
+ data.length > MIN_CACHED_CANDLES;
55
+
56
+ const inFlightRequests = new Map<string, Promise<KlineChartData>>();
22
57
 
23
58
  const useDataStore = create<DataState>((set) => ({
24
59
  data: new Map<string, KlineChartData | null>(),
@@ -26,7 +61,7 @@ const useDataStore = create<DataState>((set) => ({
26
61
  set(({ data }) => {
27
62
  const next = new Map(data);
28
63
 
29
- next.set(getKey({ provider, symbol, interval }), newData);
64
+ next.set(getKey(provider, symbol, interval), newData);
30
65
 
31
66
  return {
32
67
  data: next,
@@ -34,110 +69,180 @@ const useDataStore = create<DataState>((set) => ({
34
69
  }),
35
70
  }));
36
71
 
37
- export const useData = (filters: Filters) => {
38
- const key = getKey(filters);
39
- const prevKey = useRef(key);
40
- const retried = useRef(false);
41
- const [fulfilled, setFulfilled] = useState(false);
42
- const storedData = useDataStore((s) => s.data.get(key));
43
- const setData = useDataStore((s) => s.setData);
72
+ const loadCachedData = async (key: string) =>
73
+ ((await get(key)) as KlineChartData | null) ?? [];
44
74
 
45
- const searchParams = useSearchParams();
46
- const cacheOnly = Boolean(searchParams.get('cacheOnly')) ?? false;
75
+ const clearCachedData = async (key: string) => {
76
+ await set(key, []);
77
+ };
47
78
 
48
- useEffect(() => {
49
- if (key !== prevKey.current) {
50
- setFulfilled(false);
51
- prevKey.current = key;
52
- retried.current = false;
79
+ const getFetchStart = (start: number, data: KlineChartData) =>
80
+ Math.max(
81
+ start,
82
+ hasContinuityData(data) ? data[data.length - 2]?.timestamp || 0 : 0,
83
+ );
84
+
85
+ const requestKline = async (
86
+ request: Pick<
87
+ DataRequest,
88
+ 'provider' | 'symbol' | 'interval' | 'end' | 'cacheOnly'
89
+ > & { start: number },
90
+ ) =>
91
+ kline({
92
+ provider: request.provider,
93
+ symbol: request.symbol,
94
+ interval: request.interval,
95
+ start: request.start,
96
+ end: request.end,
97
+ cacheOnly: request.cacheOnly,
98
+ });
99
+
100
+ const loadCurrentData = async ({
101
+ key,
102
+ symbol,
103
+ interval,
104
+ }: Pick<DataRequest, 'key' | 'symbol' | 'interval'>) => {
105
+ let currentData = [
106
+ ...(useDataStore.getState().data.get(key) ?? []),
107
+ ] as KlineChartData;
108
+
109
+ if (currentData.length < MIN_CACHED_CANDLES) {
110
+ const cachedData = await loadCachedData(key);
111
+
112
+ if (hasContinuityData(cachedData)) {
113
+ currentData = [...cachedData];
53
114
  }
115
+ }
54
116
 
55
- const updateData = async () => {
56
- const { provider = 'bybit', symbol, interval, start, end } = filters;
57
- if (!symbol) {
58
- if (!fulfilled) {
59
- setFulfilled(true);
60
- }
61
- return;
62
- }
63
- let currentData = [...(storedData ?? [])];
117
+ if (hasContinuityData(currentData) && isWrongData(interval, currentData)) {
118
+ console.warn('Wrong kline continuity, drop cache', symbol, interval);
119
+ await clearCachedData(key);
120
+ return [];
121
+ }
64
122
 
65
- if (!currentData || currentData.length < 2) {
66
- const cachedResult = (await get(key)) as KlineChartData | null;
123
+ return currentData;
124
+ };
67
125
 
68
- if (cachedResult && cachedResult.length > 2) {
69
- currentData = [...cachedResult];
70
- }
71
- }
126
+ const mergeFreshData = async (
127
+ dataRequest: DataRequest,
128
+ currentData: KlineChartData,
129
+ ) => {
130
+ const incrementalData = await requestKline({
131
+ ...dataRequest,
132
+ start: getFetchStart(dataRequest.start, currentData),
133
+ });
134
+
135
+ const mergedData = mergeData(currentData, incrementalData);
136
+
137
+ if (
138
+ dataRequest.cacheOnly ||
139
+ !hasContinuityData(mergedData) ||
140
+ !isWrongData(dataRequest.interval, mergedData)
141
+ ) {
142
+ return mergedData;
143
+ }
144
+
145
+ console.warn(
146
+ 'Wrong kline continuity after merge, refetch full',
147
+ dataRequest.symbol,
148
+ dataRequest.interval,
149
+ );
150
+ await clearCachedData(dataRequest.key);
151
+
152
+ const fullData = await requestKline({
153
+ ...dataRequest,
154
+ start: dataRequest.start,
155
+ });
156
+
157
+ return mergeData([], fullData);
158
+ };
72
159
 
73
- if (currentData?.length > 2 && isWrongData(interval, currentData)) {
74
- console.warn('Wrong kline continuity, drop cache', symbol, interval);
75
- currentData = [];
76
- set(key, []);
77
- }
160
+ const persistData = async (
161
+ {
162
+ provider,
163
+ symbol,
164
+ interval,
165
+ key,
166
+ }: Pick<DataRequest, 'provider' | 'symbol' | 'interval' | 'key'>,
167
+ data: KlineChartData,
168
+ ) => {
169
+ useDataStore.getState().setData(provider, symbol, interval, data);
170
+ await set(key, data);
171
+ };
78
172
 
79
- const normStart = Math.max(
80
- start,
81
- currentData?.length > 2
82
- ? currentData[currentData.length - 2]?.timestamp || 0
83
- : 0,
84
- );
85
-
86
- const newData = await kline({
87
- provider,
88
- symbol,
89
- interval,
90
- start: normStart,
91
- end,
92
- cacheOnly,
93
- });
94
-
95
- const finalData = mergeData(currentData, newData);
96
-
97
- if (
98
- !cacheOnly &&
99
- !retried.current &&
100
- finalData.length > 2 &&
101
- isWrongData(interval, finalData)
102
- ) {
103
- console.warn(
104
- 'Wrong kline continuity after merge, refetch full',
105
- symbol,
173
+ const fetchAndStoreData = async (dataRequest: DataRequest) => {
174
+ const requestKey = getRequestKey(dataRequest);
175
+ const existingRequest = inFlightRequests.get(requestKey);
176
+
177
+ if (existingRequest) {
178
+ return existingRequest;
179
+ }
180
+
181
+ const pendingRequest = (async () => {
182
+ const currentData = await loadCurrentData(dataRequest);
183
+ const finalData = await mergeFreshData(dataRequest, currentData);
184
+ await persistData(dataRequest, finalData);
185
+
186
+ return finalData;
187
+ })().finally(() => {
188
+ inFlightRequests.delete(requestKey);
189
+ });
190
+
191
+ inFlightRequests.set(requestKey, pendingRequest);
192
+
193
+ return pendingRequest;
194
+ };
195
+
196
+ export const useData = (filters: Filters) => {
197
+ const searchParams = useSearchParams();
198
+ const cacheOnly = Boolean(searchParams.get('cacheOnly')) ?? false;
199
+ const { end, interval, provider, start, symbol } = filters;
200
+ const dataRequest = useMemo(
201
+ () =>
202
+ toRequest(
203
+ {
204
+ end,
106
205
  interval,
107
- );
108
- retried.current = true;
109
- set(key, []);
110
- const refetchData = await kline({
111
206
  provider,
112
- symbol,
113
- interval,
114
207
  start,
115
- end,
116
- cacheOnly,
117
- });
118
- const cleaned = mergeData([], refetchData);
119
- setData(provider as Provider, symbol, interval, cleaned);
120
- if (!fulfilled) {
121
- setFulfilled(true);
208
+ symbol,
209
+ } as Filters,
210
+ cacheOnly,
211
+ ),
212
+ [cacheOnly, end, interval, provider, start, symbol],
213
+ );
214
+ const [fulfilledKey, setFulfilledKey] = useState<string | null>(null);
215
+ const storedData = useDataStore((s) => s.data.get(dataRequest.key));
216
+
217
+ const fulfilled = fulfilledKey === dataRequest.key;
218
+
219
+ useEffect(() => {
220
+ let cancelled = false;
221
+
222
+ const updateData = async () => {
223
+ if (!dataRequest.symbol) {
224
+ if (!cancelled) {
225
+ setFulfilledKey(dataRequest.key);
122
226
  }
123
- set(key, cleaned);
124
227
  return;
125
228
  }
126
229
 
127
- setData(provider as Provider, symbol, interval, finalData);
230
+ await fetchAndStoreData(dataRequest);
128
231
 
129
- if (!fulfilled) {
130
- setFulfilled(true);
232
+ if (!cancelled) {
233
+ setFulfilledKey(dataRequest.key);
131
234
  }
132
-
133
- set(key, finalData);
134
235
  };
135
236
 
136
237
  void updateData();
137
- }, [cacheOnly, filters, fulfilled, key, setData, storedData]);
238
+
239
+ return () => {
240
+ cancelled = true;
241
+ };
242
+ }, [dataRequest]);
138
243
 
139
244
  return {
140
- key,
245
+ key: dataRequest.key,
141
246
  data: storedData ?? [],
142
247
  fulfilled,
143
248
  };