@tradejs/app 2.0.6 → 2.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/README.md +12 -1
- package/package.json +7 -7
- package/src/app/api/strategies/runtime/route.ts +35 -215
- package/src/app/components/Dashboard/KlineChart/figures/backtestFigureTypes.ts +1 -0
- package/src/app/components/Dashboard/KlineChart/figures/entryAnnotationPointFigure.ts +1 -0
- package/src/app/components/UI/Toaster/index.tsx +7 -5
- package/src/app/layout.tsx +38 -0
- package/src/app/lib/connectorCreator.ts +20 -1
- package/src/app/lib/runtimeTradeSync.ts +271 -0
- package/src/app/lib/yandexMetrika.ts +30 -0
- package/src/app/provider.tsx +34 -0
- package/src/app/routes/backtest/page.tsx +32 -0
- package/src/app/routes/install/page.tsx +3 -0
package/README.md
CHANGED
|
@@ -26,8 +26,19 @@ npx tradejs-app dev
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
Use matching versions for all `@tradejs/*` packages. The installable launcher
|
|
29
|
-
|
|
29
|
+
is included in current `@tradejs/app` releases.
|
|
30
30
|
|
|
31
31
|
The launcher reads env and `tradejs.config.ts` from the caller project directory
|
|
32
32
|
via `PROJECT_CWD`. When it runs from `node_modules`, it creates a generated
|
|
33
33
|
`.tradejs/app` working copy and runs Next.js there.
|
|
34
|
+
|
|
35
|
+
## Anonymous Onboarding Telemetry
|
|
36
|
+
|
|
37
|
+
The Web UI reports only the anonymous Yandex Metrica goal names
|
|
38
|
+
`scaffold_success` and `first_backtest`. It does not include strategy
|
|
39
|
+
configuration, symbols, credentials, or backtest results. Disable these events
|
|
40
|
+
before starting or building the app with:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
NEXT_PUBLIC_TRADEJS_TELEMETRY_DISABLED=1
|
|
44
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tradejs/app",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
4
4
|
"description": "Installable Next.js UI for the TradeJS TypeScript framework: dashboards, backtests, charts, and runtime data.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tradejs",
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
"@emotion/react": "^11.14.0",
|
|
52
52
|
"@langchain/core": "^1.2.3",
|
|
53
53
|
"@langchain/openai": "^1.5.5",
|
|
54
|
-
"@tradejs/connectors": "^2.0.
|
|
55
|
-
"@tradejs/core": "^2.0.
|
|
56
|
-
"@tradejs/indicators": "^2.0.
|
|
57
|
-
"@tradejs/infra": "^2.0.
|
|
58
|
-
"@tradejs/node": "^2.0.
|
|
59
|
-
"@tradejs/types": "^2.0.
|
|
54
|
+
"@tradejs/connectors": "^2.0.8",
|
|
55
|
+
"@tradejs/core": "^2.0.8",
|
|
56
|
+
"@tradejs/indicators": "^2.0.8",
|
|
57
|
+
"@tradejs/infra": "^2.0.8",
|
|
58
|
+
"@tradejs/node": "^2.0.8",
|
|
59
|
+
"@tradejs/types": "^2.0.8",
|
|
60
60
|
"@types/bcryptjs": "2.4.6",
|
|
61
61
|
"@types/lodash": "4.17.24",
|
|
62
62
|
"@types/node": "24.13.3",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { TTL_1M } from '@tradejs/core/constants';
|
|
3
2
|
import { getRuntimeStorageDayKeys } from '@tradejs/core/time';
|
|
4
3
|
import { logger } from '@tradejs/infra/logger';
|
|
5
4
|
import {
|
|
@@ -9,17 +8,13 @@ import {
|
|
|
9
8
|
} from '@tradejs/infra/tradingAccounts';
|
|
10
9
|
import { strategyEntries } from '@tradejs/strategies';
|
|
11
10
|
import {
|
|
12
|
-
delKey,
|
|
13
11
|
getData,
|
|
14
12
|
getHashJsonValues,
|
|
15
13
|
getKeys,
|
|
16
14
|
redisKeys,
|
|
17
|
-
setData,
|
|
18
15
|
} from '@tradejs/infra/redis';
|
|
19
16
|
import type {
|
|
20
17
|
Connector,
|
|
21
|
-
ConnectorCreator,
|
|
22
|
-
PositionPnlSnapshot,
|
|
23
18
|
RuntimeTradeRecord,
|
|
24
19
|
Interval,
|
|
25
20
|
StrategyConfig,
|
|
@@ -27,6 +22,7 @@ import type {
|
|
|
27
22
|
import { getAvailableStrategyNames } from '@tradejs/node/strategies';
|
|
28
23
|
import {
|
|
29
24
|
DEFAULT_CONNECTOR_PROVIDER,
|
|
25
|
+
resolveConnectorAccountId,
|
|
30
26
|
resolveConnectorCreatorByProvider,
|
|
31
27
|
} from '#app/lib/connectorCreator';
|
|
32
28
|
import { getCurrentUserName } from '#app/lib/currentUser';
|
|
@@ -39,15 +35,12 @@ import {
|
|
|
39
35
|
resolveStrategyConfigIdentityByKey,
|
|
40
36
|
RuntimeStrategiesResponse,
|
|
41
37
|
selectTradesForWindow,
|
|
42
|
-
takeClosedPnlMatch,
|
|
43
38
|
toRuntimeTradeView,
|
|
44
39
|
} from '#app/lib/runtimeStrategies';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
orderLinkId?: string;
|
|
50
|
-
};
|
|
40
|
+
import {
|
|
41
|
+
isRuntimeTradeInConnectorScope,
|
|
42
|
+
syncRuntimeTrades,
|
|
43
|
+
} from '#app/lib/runtimeTradeSync';
|
|
51
44
|
|
|
52
45
|
export const dynamic = 'force-dynamic';
|
|
53
46
|
|
|
@@ -322,209 +315,27 @@ const loadExchangeEntryRows = async ({
|
|
|
322
315
|
const loadOpenPositions = async (
|
|
323
316
|
connector: Connector,
|
|
324
317
|
errors?: string[],
|
|
325
|
-
): Promise<
|
|
318
|
+
): Promise<{
|
|
319
|
+
positions: Awaited<ReturnType<NonNullable<Connector['getOpenPositionPnl']>>>;
|
|
320
|
+
reliable: boolean;
|
|
321
|
+
}> => {
|
|
326
322
|
if (typeof connector.getOpenPositionPnl !== 'function') {
|
|
327
|
-
return [];
|
|
323
|
+
return { positions: [], reliable: false };
|
|
328
324
|
}
|
|
329
325
|
|
|
330
326
|
try {
|
|
331
|
-
return
|
|
327
|
+
return {
|
|
328
|
+
positions: await connector.getOpenPositionPnl(),
|
|
329
|
+
reliable: true,
|
|
330
|
+
};
|
|
332
331
|
} catch (error) {
|
|
333
332
|
const message = (error as Error)?.message || String(error);
|
|
334
333
|
errors?.push(`getOpenPositionPnl: ${message}`);
|
|
335
334
|
logger.warn('strategies runtime: getOpenPositionPnl failed: %s', message);
|
|
336
|
-
return [];
|
|
335
|
+
return { positions: [], reliable: false };
|
|
337
336
|
}
|
|
338
337
|
};
|
|
339
338
|
|
|
340
|
-
const buildRiskLevelsAnalysis = (position: PositionPnlSnapshot) => {
|
|
341
|
-
const takeProfitPrice =
|
|
342
|
-
typeof position.takeProfitPrice === 'number' &&
|
|
343
|
-
Number.isFinite(position.takeProfitPrice)
|
|
344
|
-
? position.takeProfitPrice
|
|
345
|
-
: null;
|
|
346
|
-
const stopLossPrice =
|
|
347
|
-
typeof position.stopLossPrice === 'number' &&
|
|
348
|
-
Number.isFinite(position.stopLossPrice)
|
|
349
|
-
? position.stopLossPrice
|
|
350
|
-
: null;
|
|
351
|
-
|
|
352
|
-
if (takeProfitPrice == null && stopLossPrice == null) {
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return {
|
|
357
|
-
...(takeProfitPrice != null ? { takeProfitPrice } : {}),
|
|
358
|
-
...(stopLossPrice != null ? { stopLossPrice } : {}),
|
|
359
|
-
};
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
const syncRuntimeTrades = async ({
|
|
363
|
-
userName,
|
|
364
|
-
trades,
|
|
365
|
-
endTime,
|
|
366
|
-
openPositions,
|
|
367
|
-
closedPnlRows,
|
|
368
|
-
}: {
|
|
369
|
-
userName: string;
|
|
370
|
-
trades: RuntimeTradeRecord[];
|
|
371
|
-
endTime: number;
|
|
372
|
-
openPositions: PositionPnlSnapshot[];
|
|
373
|
-
closedPnlRows: ClosedPnlRecordWithOrderLinkId[];
|
|
374
|
-
}) => {
|
|
375
|
-
const openPositionsBySymbol = new Map(
|
|
376
|
-
openPositions.map((position) => [position.symbol, position]),
|
|
377
|
-
);
|
|
378
|
-
const activeOrderIdBySymbol = new Map<string, string | null>();
|
|
379
|
-
const symbols = [...new Set(trades.map((trade) => trade.symbol))];
|
|
380
|
-
|
|
381
|
-
await Promise.all(
|
|
382
|
-
symbols.map(async (symbol) => {
|
|
383
|
-
const activeRef = (await getData(
|
|
384
|
-
redisKeys.runtimeActiveTrade(userName, symbol),
|
|
385
|
-
null,
|
|
386
|
-
)) as { orderId?: string } | null;
|
|
387
|
-
activeOrderIdBySymbol.set(
|
|
388
|
-
symbol,
|
|
389
|
-
typeof activeRef?.orderId === 'string' ? activeRef.orderId : null,
|
|
390
|
-
);
|
|
391
|
-
}),
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
const closedPnlRowsWithOrderLinkId =
|
|
395
|
-
closedPnlRows as ClosedPnlRecordWithOrderLinkId[];
|
|
396
|
-
const exactByOrderLinkId = new Map(
|
|
397
|
-
closedPnlRowsWithOrderLinkId
|
|
398
|
-
.filter(
|
|
399
|
-
(row): row is typeof row & { orderLinkId: string } =>
|
|
400
|
-
typeof row.orderLinkId === 'string' && row.orderLinkId.length > 0,
|
|
401
|
-
)
|
|
402
|
-
.map((row) => [row.orderLinkId, row]),
|
|
403
|
-
);
|
|
404
|
-
const exactByOrderId = new Map(
|
|
405
|
-
closedPnlRowsWithOrderLinkId
|
|
406
|
-
.filter(
|
|
407
|
-
(row): row is typeof row & { orderId: string } =>
|
|
408
|
-
typeof row.orderId === 'string' && row.orderId.length > 0,
|
|
409
|
-
)
|
|
410
|
-
.map((row) => [row.orderId, row]),
|
|
411
|
-
);
|
|
412
|
-
const symbolBuckets = new Map<string, ClosedPnlRecordWithOrderLinkId[]>();
|
|
413
|
-
|
|
414
|
-
for (const row of closedPnlRowsWithOrderLinkId) {
|
|
415
|
-
const bucket = symbolBuckets.get(row.symbol) ?? [];
|
|
416
|
-
bucket.push(row);
|
|
417
|
-
symbolBuckets.set(row.symbol, bucket);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const syncedTrades: RuntimeTradeRecord[] = [];
|
|
421
|
-
|
|
422
|
-
for (const trade of trades) {
|
|
423
|
-
const closedTradeHasExchangeDetails =
|
|
424
|
-
trade.status === 'closed' &&
|
|
425
|
-
typeof trade.exitPrice === 'number' &&
|
|
426
|
-
Number.isFinite(trade.exitPrice) &&
|
|
427
|
-
typeof trade.actualExitPrice === 'number' &&
|
|
428
|
-
Number.isFinite(trade.actualExitPrice) &&
|
|
429
|
-
typeof trade.closedPnl === 'number' &&
|
|
430
|
-
Number.isFinite(trade.closedPnl) &&
|
|
431
|
-
typeof trade.openFee === 'number' &&
|
|
432
|
-
Number.isFinite(trade.openFee) &&
|
|
433
|
-
typeof trade.closeFee === 'number' &&
|
|
434
|
-
Number.isFinite(trade.closeFee);
|
|
435
|
-
|
|
436
|
-
if (trade.status !== 'active' && closedTradeHasExchangeDetails) {
|
|
437
|
-
syncedTrades.push(trade);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const openPosition = openPositionsBySymbol.get(trade.symbol);
|
|
442
|
-
const activeOrderId = activeOrderIdBySymbol.get(trade.symbol);
|
|
443
|
-
const isCurrentActiveTrade = activeOrderId === trade.orderId;
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
isCurrentActiveTrade &&
|
|
447
|
-
openPosition &&
|
|
448
|
-
openPosition.direction === trade.direction
|
|
449
|
-
) {
|
|
450
|
-
const riskLevelsAnalysis = buildRiskLevelsAnalysis(openPosition);
|
|
451
|
-
const nextTrade: RuntimeTradeRecord = {
|
|
452
|
-
...trade,
|
|
453
|
-
status: 'active',
|
|
454
|
-
currentPrice: openPosition.currentPrice,
|
|
455
|
-
currentPnl: openPosition.unrealizedPnl,
|
|
456
|
-
aiAnalysis: riskLevelsAnalysis
|
|
457
|
-
? { ...(trade.aiAnalysis ?? {}), ...riskLevelsAnalysis }
|
|
458
|
-
: trade.aiAnalysis,
|
|
459
|
-
lastSyncedAt: endTime,
|
|
460
|
-
};
|
|
461
|
-
|
|
462
|
-
await setData(
|
|
463
|
-
redisKeys.runtimeTrade(userName, trade.orderId),
|
|
464
|
-
nextTrade,
|
|
465
|
-
{
|
|
466
|
-
expire: 0,
|
|
467
|
-
},
|
|
468
|
-
);
|
|
469
|
-
syncedTrades.push(nextTrade);
|
|
470
|
-
continue;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const matchedClosedPnl = takeClosedPnlMatch({
|
|
474
|
-
exactByOrderLinkId,
|
|
475
|
-
exactByOrderId,
|
|
476
|
-
symbolBuckets,
|
|
477
|
-
trade,
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
if (trade.status === 'closed' && !matchedClosedPnl) {
|
|
481
|
-
syncedTrades.push(trade);
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const nextTrade: RuntimeTradeRecord = {
|
|
486
|
-
...trade,
|
|
487
|
-
status: 'closed',
|
|
488
|
-
currentPrice: matchedClosedPnl?.exitPrice ?? trade.currentPrice ?? null,
|
|
489
|
-
currentPnl:
|
|
490
|
-
matchedClosedPnl?.closedPnl ??
|
|
491
|
-
trade.closedPnl ??
|
|
492
|
-
trade.currentPnl ??
|
|
493
|
-
null,
|
|
494
|
-
closedPnl:
|
|
495
|
-
matchedClosedPnl?.closedPnl ??
|
|
496
|
-
trade.closedPnl ??
|
|
497
|
-
trade.currentPnl ??
|
|
498
|
-
null,
|
|
499
|
-
actualEntryPrice:
|
|
500
|
-
matchedClosedPnl?.entryPrice ?? trade.actualEntryPrice ?? null,
|
|
501
|
-
exitPrice: matchedClosedPnl?.exitPrice ?? trade.exitPrice ?? null,
|
|
502
|
-
actualExitPrice:
|
|
503
|
-
matchedClosedPnl?.exitPrice ?? trade.actualExitPrice ?? null,
|
|
504
|
-
exitTimestamp:
|
|
505
|
-
matchedClosedPnl?.closedAt ?? trade.exitTimestamp ?? endTime,
|
|
506
|
-
exitType: trade.exitType ?? null,
|
|
507
|
-
openFee: matchedClosedPnl?.openFee ?? trade.openFee ?? null,
|
|
508
|
-
closeFee: matchedClosedPnl?.closeFee ?? trade.closeFee ?? null,
|
|
509
|
-
fundingFee: matchedClosedPnl?.fundingFee ?? trade.fundingFee ?? null,
|
|
510
|
-
totalFee: matchedClosedPnl?.totalFee ?? trade.totalFee ?? null,
|
|
511
|
-
lastSyncedAt: endTime,
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
await Promise.all([
|
|
515
|
-
setData(redisKeys.runtimeTrade(userName, trade.orderId), nextTrade, {
|
|
516
|
-
expire: TTL_1M,
|
|
517
|
-
}),
|
|
518
|
-
...(isCurrentActiveTrade
|
|
519
|
-
? [delKey(redisKeys.runtimeActiveTrade(userName, trade.symbol))]
|
|
520
|
-
: []),
|
|
521
|
-
]);
|
|
522
|
-
syncedTrades.push(nextTrade);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
return syncedTrades;
|
|
526
|
-
};
|
|
527
|
-
|
|
528
339
|
export const GET = async (request: NextRequest) => {
|
|
529
340
|
try {
|
|
530
341
|
const userName = await getCurrentUserName();
|
|
@@ -548,8 +359,15 @@ export const GET = async (request: NextRequest) => {
|
|
|
548
359
|
throw new Error(`No connector available for provider "${provider}"`);
|
|
549
360
|
}
|
|
550
361
|
|
|
551
|
-
const
|
|
362
|
+
const connectorAccountId = await resolveConnectorAccountId({
|
|
363
|
+
userName,
|
|
364
|
+
provider,
|
|
365
|
+
universe: 'crypto',
|
|
366
|
+
});
|
|
367
|
+
const connector = await connectorCreator({
|
|
552
368
|
userName,
|
|
369
|
+
accountId: connectorAccountId,
|
|
370
|
+
universe: 'crypto',
|
|
553
371
|
});
|
|
554
372
|
|
|
555
373
|
const [
|
|
@@ -559,7 +377,7 @@ export const GET = async (request: NextRequest) => {
|
|
|
559
377
|
activeOrderIds,
|
|
560
378
|
closedPnlRows,
|
|
561
379
|
entryRows,
|
|
562
|
-
|
|
380
|
+
openPositionsSnapshot,
|
|
563
381
|
runtimeDeployments,
|
|
564
382
|
tradingAccounts,
|
|
565
383
|
] = await Promise.all([
|
|
@@ -588,20 +406,22 @@ export const GET = async (request: NextRequest) => {
|
|
|
588
406
|
startTime,
|
|
589
407
|
activeOrderIds,
|
|
590
408
|
);
|
|
591
|
-
const
|
|
592
|
-
|
|
409
|
+
const syncableTrades = relevantTrades.filter((trade) =>
|
|
410
|
+
isRuntimeTradeInConnectorScope(trade, connector),
|
|
593
411
|
);
|
|
594
|
-
const
|
|
595
|
-
(trade) => !trade
|
|
412
|
+
const unsyncedTrades = relevantTrades.filter(
|
|
413
|
+
(trade) => !isRuntimeTradeInConnectorScope(trade, connector),
|
|
596
414
|
);
|
|
597
|
-
const
|
|
415
|
+
const syncedConnectorTrades = await syncRuntimeTrades({
|
|
598
416
|
userName,
|
|
599
|
-
|
|
417
|
+
connector,
|
|
418
|
+
trades: syncableTrades,
|
|
600
419
|
endTime,
|
|
601
|
-
openPositions,
|
|
420
|
+
openPositions: openPositionsSnapshot.positions,
|
|
421
|
+
openPositionsReliable: openPositionsSnapshot.reliable,
|
|
602
422
|
closedPnlRows,
|
|
603
423
|
});
|
|
604
|
-
const syncedTrades = [...
|
|
424
|
+
const syncedTrades = [...unsyncedTrades, ...syncedConnectorTrades];
|
|
605
425
|
const fallbackStrategyNames = [
|
|
606
426
|
...new Set([
|
|
607
427
|
...runtimeStrategyConfigs.map(({ strategyName }) => strategyName),
|
|
@@ -611,7 +431,7 @@ export const GET = async (request: NextRequest) => {
|
|
|
611
431
|
const fallbackTrades = buildExchangeFallbackRuntimeTrades({
|
|
612
432
|
entryRows,
|
|
613
433
|
closedPnlRows,
|
|
614
|
-
openPositions,
|
|
434
|
+
openPositions: openPositionsSnapshot.positions,
|
|
615
435
|
strategyNames: fallbackStrategyNames,
|
|
616
436
|
existingTrades: syncedTrades,
|
|
617
437
|
endTime,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createEntryAnnotationPointFigure } from '@tradejs/core/figures';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Portal, Toast, Toaster, createToaster } from '@chakra-ui/react';
|
|
3
|
+
import { Portal, Stack, Toast, Toaster, createToaster } from '@chakra-ui/react';
|
|
4
4
|
|
|
5
5
|
export const toaster = createToaster({
|
|
6
6
|
placement: 'bottom-end',
|
|
@@ -32,10 +32,12 @@ export const AppToaster = () => {
|
|
|
32
32
|
: 'gray.300'
|
|
33
33
|
}
|
|
34
34
|
/>
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
<Stack gap="1" flex="1" minW="0">
|
|
36
|
+
<Toast.Title>{toast.title}</Toast.Title>
|
|
37
|
+
{toast.description ? (
|
|
38
|
+
<Toast.Description>{toast.description}</Toast.Description>
|
|
39
|
+
) : null}
|
|
40
|
+
</Stack>
|
|
39
41
|
<Toast.CloseTrigger />
|
|
40
42
|
</Toast.Root>
|
|
41
43
|
)}
|
package/src/app/layout.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
+
import Script from 'next/script';
|
|
2
3
|
import { AppShell } from '#shared/AppShell';
|
|
4
|
+
import { YANDEX_METRIKA_COUNTER_ID } from '#app/lib/yandexMetrika';
|
|
3
5
|
import Provider from './provider';
|
|
4
6
|
import './globals.css';
|
|
5
7
|
|
|
@@ -53,6 +55,9 @@ export default function RootLayout({
|
|
|
53
55
|
}: Readonly<{
|
|
54
56
|
children: React.ReactNode;
|
|
55
57
|
}>) {
|
|
58
|
+
const telemetryEnabled =
|
|
59
|
+
process.env.NEXT_PUBLIC_TRADEJS_TELEMETRY_DISABLED !== '1';
|
|
60
|
+
|
|
56
61
|
return (
|
|
57
62
|
<html
|
|
58
63
|
lang="en"
|
|
@@ -61,6 +66,39 @@ export default function RootLayout({
|
|
|
61
66
|
suppressHydrationWarning
|
|
62
67
|
>
|
|
63
68
|
<body suppressHydrationWarning>
|
|
69
|
+
{telemetryEnabled ? (
|
|
70
|
+
<>
|
|
71
|
+
<Script id="yandex-metrika" strategy="afterInteractive">
|
|
72
|
+
{`
|
|
73
|
+
(function(m,e,t,r,i,k,a){
|
|
74
|
+
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
|
75
|
+
m[i].l=1*new Date();
|
|
76
|
+
for (var j = 0; j < document.scripts.length; j++) {
|
|
77
|
+
if (document.scripts[j].src === r) { return; }
|
|
78
|
+
}
|
|
79
|
+
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a);
|
|
80
|
+
})(window, document, 'script', 'https://mc.yandex.ru/metrika/tag.js?id=${YANDEX_METRIKA_COUNTER_ID}', 'ym');
|
|
81
|
+
|
|
82
|
+
ym(${YANDEX_METRIKA_COUNTER_ID}, 'init', {
|
|
83
|
+
clickmap: false,
|
|
84
|
+
ecommerce: false,
|
|
85
|
+
accurateTrackBounce: true,
|
|
86
|
+
trackLinks: false
|
|
87
|
+
});
|
|
88
|
+
`}
|
|
89
|
+
</Script>
|
|
90
|
+
<noscript>
|
|
91
|
+
<div>
|
|
92
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
93
|
+
<img
|
|
94
|
+
src={`https://mc.yandex.ru/watch/${YANDEX_METRIKA_COUNTER_ID}`}
|
|
95
|
+
style={{ position: 'absolute', left: '-9999px' }}
|
|
96
|
+
alt=""
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
</noscript>
|
|
100
|
+
</>
|
|
101
|
+
) : null}
|
|
64
102
|
<Provider>
|
|
65
103
|
<AppShell>{children}</AppShell>
|
|
66
104
|
</Provider>
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { getConnectorCreatorByProvider as getBuiltinConnectorCreatorByProvider } from '@tradejs/connectors';
|
|
2
|
+
import { resolveTradingAccount } from '@tradejs/infra/tradingAccounts';
|
|
2
3
|
import { getConnectorCreatorByProvider as getRegisteredConnectorCreatorByProvider } from '@tradejs/node/connectors';
|
|
3
|
-
import { ConnectorCreator } from '@tradejs/types';
|
|
4
|
+
import { ConnectorCreator, MarketUniverse } from '@tradejs/types';
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_CONNECTOR_PROVIDER = 'bybit';
|
|
6
7
|
|
|
8
|
+
export const resolveConnectorAccountId = async ({
|
|
9
|
+
userName,
|
|
10
|
+
provider,
|
|
11
|
+
universe,
|
|
12
|
+
}: {
|
|
13
|
+
userName: string;
|
|
14
|
+
provider: string;
|
|
15
|
+
universe: MarketUniverse;
|
|
16
|
+
}) => {
|
|
17
|
+
const account = await resolveTradingAccount({
|
|
18
|
+
userName,
|
|
19
|
+
provider,
|
|
20
|
+
universe,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return account?.id;
|
|
24
|
+
};
|
|
25
|
+
|
|
7
26
|
export const resolveConnectorCreatorByProvider = async (
|
|
8
27
|
provider: string,
|
|
9
28
|
projectRoot: string,
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { TTL_1M } from '@tradejs/core/constants';
|
|
2
|
+
import { getRuntimeStorageDayKey } from '@tradejs/core/time';
|
|
3
|
+
import {
|
|
4
|
+
delKey,
|
|
5
|
+
getData,
|
|
6
|
+
redisKeys,
|
|
7
|
+
setData,
|
|
8
|
+
setHashJsonField,
|
|
9
|
+
} from '@tradejs/infra/redis';
|
|
10
|
+
import type {
|
|
11
|
+
ClosedPnlRecord,
|
|
12
|
+
Connector,
|
|
13
|
+
PositionPnlSnapshot,
|
|
14
|
+
RuntimeTradeRecord,
|
|
15
|
+
} from '@tradejs/types';
|
|
16
|
+
import { takeClosedPnlMatch } from './runtimeStrategies';
|
|
17
|
+
|
|
18
|
+
export type ClosedPnlRecordWithOrderLinkId = ClosedPnlRecord & {
|
|
19
|
+
orderLinkId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getRuntimeTradeScopeId = (trade: RuntimeTradeRecord) =>
|
|
23
|
+
trade.deploymentId ?? trade.accountId;
|
|
24
|
+
|
|
25
|
+
export const isRuntimeTradeInConnectorScope = (
|
|
26
|
+
trade: RuntimeTradeRecord,
|
|
27
|
+
connector: Connector,
|
|
28
|
+
) => {
|
|
29
|
+
if (trade.deploymentId && trade.deploymentId !== connector.deploymentId) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (trade.deploymentId && !connector.deploymentId) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (trade.accountId && trade.accountId !== connector.accountId) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (trade.accountId && !connector.accountId) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (trade.universe ?? 'crypto') === connector.universe;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const buildRiskLevelsAnalysis = (position: PositionPnlSnapshot) => {
|
|
46
|
+
const takeProfitPrice =
|
|
47
|
+
typeof position.takeProfitPrice === 'number' &&
|
|
48
|
+
Number.isFinite(position.takeProfitPrice)
|
|
49
|
+
? position.takeProfitPrice
|
|
50
|
+
: null;
|
|
51
|
+
const stopLossPrice =
|
|
52
|
+
typeof position.stopLossPrice === 'number' &&
|
|
53
|
+
Number.isFinite(position.stopLossPrice)
|
|
54
|
+
? position.stopLossPrice
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
if (takeProfitPrice == null && stopLossPrice == null) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...(takeProfitPrice != null ? { takeProfitPrice } : {}),
|
|
63
|
+
...(stopLossPrice != null ? { stopLossPrice } : {}),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const hasExchangeCloseDetails = (trade: RuntimeTradeRecord) =>
|
|
68
|
+
trade.status === 'closed' &&
|
|
69
|
+
typeof trade.exitPrice === 'number' &&
|
|
70
|
+
Number.isFinite(trade.exitPrice) &&
|
|
71
|
+
typeof trade.actualExitPrice === 'number' &&
|
|
72
|
+
Number.isFinite(trade.actualExitPrice) &&
|
|
73
|
+
typeof trade.closedPnl === 'number' &&
|
|
74
|
+
Number.isFinite(trade.closedPnl) &&
|
|
75
|
+
typeof trade.openFee === 'number' &&
|
|
76
|
+
Number.isFinite(trade.openFee) &&
|
|
77
|
+
typeof trade.closeFee === 'number' &&
|
|
78
|
+
Number.isFinite(trade.closeFee);
|
|
79
|
+
|
|
80
|
+
export const syncRuntimeTrades = async ({
|
|
81
|
+
userName,
|
|
82
|
+
connector,
|
|
83
|
+
trades,
|
|
84
|
+
endTime,
|
|
85
|
+
openPositions,
|
|
86
|
+
openPositionsReliable,
|
|
87
|
+
closedPnlRows,
|
|
88
|
+
}: {
|
|
89
|
+
userName: string;
|
|
90
|
+
connector: Connector;
|
|
91
|
+
trades: RuntimeTradeRecord[];
|
|
92
|
+
endTime: number;
|
|
93
|
+
openPositions: PositionPnlSnapshot[];
|
|
94
|
+
openPositionsReliable: boolean;
|
|
95
|
+
closedPnlRows: ClosedPnlRecordWithOrderLinkId[];
|
|
96
|
+
}) => {
|
|
97
|
+
const openPositionsBySymbol = new Map(
|
|
98
|
+
openPositions.map((position) => [position.symbol, position]),
|
|
99
|
+
);
|
|
100
|
+
const activeOrderIdByKey = new Map<string, string | null>();
|
|
101
|
+
const activeTradeKeys = [
|
|
102
|
+
...new Set(
|
|
103
|
+
trades
|
|
104
|
+
.filter((trade) => isRuntimeTradeInConnectorScope(trade, connector))
|
|
105
|
+
.map((trade) =>
|
|
106
|
+
redisKeys.runtimeActiveTrade(
|
|
107
|
+
userName,
|
|
108
|
+
trade.symbol,
|
|
109
|
+
getRuntimeTradeScopeId(trade),
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
await Promise.all(
|
|
116
|
+
activeTradeKeys.map(async (key) => {
|
|
117
|
+
const activeRef = (await getData(key, null)) as {
|
|
118
|
+
orderId?: string;
|
|
119
|
+
} | null;
|
|
120
|
+
activeOrderIdByKey.set(
|
|
121
|
+
key,
|
|
122
|
+
typeof activeRef?.orderId === 'string' ? activeRef.orderId : null,
|
|
123
|
+
);
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const exactByOrderLinkId = new Map(
|
|
128
|
+
closedPnlRows
|
|
129
|
+
.filter(
|
|
130
|
+
(
|
|
131
|
+
row,
|
|
132
|
+
): row is ClosedPnlRecordWithOrderLinkId & { orderLinkId: string } =>
|
|
133
|
+
typeof row.orderLinkId === 'string' && row.orderLinkId.length > 0,
|
|
134
|
+
)
|
|
135
|
+
.map((row) => [row.orderLinkId, row]),
|
|
136
|
+
);
|
|
137
|
+
const exactByOrderId = new Map(
|
|
138
|
+
closedPnlRows
|
|
139
|
+
.filter(
|
|
140
|
+
(row): row is ClosedPnlRecordWithOrderLinkId & { orderId: string } =>
|
|
141
|
+
typeof row.orderId === 'string' && row.orderId.length > 0,
|
|
142
|
+
)
|
|
143
|
+
.map((row) => [row.orderId, row]),
|
|
144
|
+
);
|
|
145
|
+
const symbolBuckets = new Map<string, ClosedPnlRecordWithOrderLinkId[]>();
|
|
146
|
+
|
|
147
|
+
for (const row of closedPnlRows) {
|
|
148
|
+
const bucket = symbolBuckets.get(row.symbol) ?? [];
|
|
149
|
+
bucket.push(row);
|
|
150
|
+
symbolBuckets.set(row.symbol, bucket);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const syncedTrades: RuntimeTradeRecord[] = [];
|
|
154
|
+
|
|
155
|
+
for (const trade of trades) {
|
|
156
|
+
if (!isRuntimeTradeInConnectorScope(trade, connector)) {
|
|
157
|
+
syncedTrades.push(trade);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const activeTradeKey = redisKeys.runtimeActiveTrade(
|
|
162
|
+
userName,
|
|
163
|
+
trade.symbol,
|
|
164
|
+
getRuntimeTradeScopeId(trade),
|
|
165
|
+
);
|
|
166
|
+
const isCurrentActiveTrade =
|
|
167
|
+
activeOrderIdByKey.get(activeTradeKey) === trade.orderId;
|
|
168
|
+
|
|
169
|
+
if (hasExchangeCloseDetails(trade)) {
|
|
170
|
+
if (isCurrentActiveTrade) {
|
|
171
|
+
await delKey(activeTradeKey);
|
|
172
|
+
}
|
|
173
|
+
syncedTrades.push(trade);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const openPosition = openPositionsBySymbol.get(trade.symbol);
|
|
178
|
+
|
|
179
|
+
if (trade.status === 'active' && !openPositionsReliable) {
|
|
180
|
+
syncedTrades.push(trade);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
trade.status === 'active' &&
|
|
186
|
+
isCurrentActiveTrade &&
|
|
187
|
+
openPosition &&
|
|
188
|
+
openPosition.direction === trade.direction
|
|
189
|
+
) {
|
|
190
|
+
const riskLevelsAnalysis = buildRiskLevelsAnalysis(openPosition);
|
|
191
|
+
const nextTrade: RuntimeTradeRecord = {
|
|
192
|
+
...trade,
|
|
193
|
+
status: 'active',
|
|
194
|
+
currentPrice: openPosition.currentPrice,
|
|
195
|
+
currentPnl: openPosition.unrealizedPnl,
|
|
196
|
+
aiAnalysis: riskLevelsAnalysis
|
|
197
|
+
? { ...(trade.aiAnalysis ?? {}), ...riskLevelsAnalysis }
|
|
198
|
+
: trade.aiAnalysis,
|
|
199
|
+
lastSyncedAt: endTime,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await Promise.all([
|
|
203
|
+
setData(redisKeys.runtimeTrade(userName, trade.orderId), nextTrade, {
|
|
204
|
+
expire: 0,
|
|
205
|
+
}),
|
|
206
|
+
setHashJsonField(
|
|
207
|
+
redisKeys.runtimeTradeBucket(
|
|
208
|
+
userName,
|
|
209
|
+
getRuntimeStorageDayKey(trade.entryTimestamp),
|
|
210
|
+
),
|
|
211
|
+
trade.orderId,
|
|
212
|
+
nextTrade,
|
|
213
|
+
{ expire: 0 },
|
|
214
|
+
),
|
|
215
|
+
]);
|
|
216
|
+
syncedTrades.push(nextTrade);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const matchedClosedPnl = takeClosedPnlMatch({
|
|
221
|
+
exactByOrderLinkId,
|
|
222
|
+
exactByOrderId,
|
|
223
|
+
symbolBuckets,
|
|
224
|
+
trade,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!matchedClosedPnl) {
|
|
228
|
+
syncedTrades.push(trade);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const nextTrade: RuntimeTradeRecord = {
|
|
233
|
+
...trade,
|
|
234
|
+
status: 'closed',
|
|
235
|
+
currentPrice: matchedClosedPnl.exitPrice ?? trade.currentPrice ?? null,
|
|
236
|
+
currentPnl: matchedClosedPnl.closedPnl,
|
|
237
|
+
closedPnl: matchedClosedPnl.closedPnl,
|
|
238
|
+
actualEntryPrice:
|
|
239
|
+
matchedClosedPnl.entryPrice ?? trade.actualEntryPrice ?? null,
|
|
240
|
+
exitPrice: matchedClosedPnl.exitPrice ?? trade.exitPrice ?? null,
|
|
241
|
+
actualExitPrice:
|
|
242
|
+
matchedClosedPnl.exitPrice ?? trade.actualExitPrice ?? null,
|
|
243
|
+
exitTimestamp: matchedClosedPnl.closedAt,
|
|
244
|
+
exitType: trade.exitType ?? null,
|
|
245
|
+
openFee: matchedClosedPnl.openFee ?? trade.openFee ?? null,
|
|
246
|
+
closeFee: matchedClosedPnl.closeFee ?? trade.closeFee ?? null,
|
|
247
|
+
fundingFee: matchedClosedPnl.fundingFee ?? trade.fundingFee ?? null,
|
|
248
|
+
totalFee: matchedClosedPnl.totalFee ?? trade.totalFee ?? null,
|
|
249
|
+
lastSyncedAt: endTime,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
await Promise.all([
|
|
253
|
+
setData(redisKeys.runtimeTrade(userName, trade.orderId), nextTrade, {
|
|
254
|
+
expire: TTL_1M,
|
|
255
|
+
}),
|
|
256
|
+
setHashJsonField(
|
|
257
|
+
redisKeys.runtimeTradeBucket(
|
|
258
|
+
userName,
|
|
259
|
+
getRuntimeStorageDayKey(trade.entryTimestamp),
|
|
260
|
+
),
|
|
261
|
+
trade.orderId,
|
|
262
|
+
nextTrade,
|
|
263
|
+
{ expire: TTL_1M },
|
|
264
|
+
),
|
|
265
|
+
...(isCurrentActiveTrade ? [delKey(activeTradeKey)] : []),
|
|
266
|
+
]);
|
|
267
|
+
syncedTrades.push(nextTrade);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return syncedTrades;
|
|
271
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const YANDEX_METRIKA_COUNTER_ID = 107254154;
|
|
2
|
+
|
|
3
|
+
export type YandexMetrikaGoal = 'scaffold_success' | 'first_backtest';
|
|
4
|
+
|
|
5
|
+
type YandexMetrika = (
|
|
6
|
+
counterId: number,
|
|
7
|
+
method: 'reachGoal',
|
|
8
|
+
target: YandexMetrikaGoal,
|
|
9
|
+
params?: Record<string, unknown>,
|
|
10
|
+
) => void;
|
|
11
|
+
|
|
12
|
+
export const reachYandexMetrikaGoal = (
|
|
13
|
+
target: YandexMetrikaGoal,
|
|
14
|
+
params?: Record<string, unknown>,
|
|
15
|
+
) => {
|
|
16
|
+
if (
|
|
17
|
+
typeof window === 'undefined' ||
|
|
18
|
+
process.env.NEXT_PUBLIC_TRADEJS_TELEMETRY_DISABLED === '1'
|
|
19
|
+
) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ym = (window as Window & { ym?: YandexMetrika }).ym;
|
|
24
|
+
if (!ym) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ym(YANDEX_METRIKA_COUNTER_ID, 'reachGoal', target, params);
|
|
29
|
+
return true;
|
|
30
|
+
};
|
package/src/app/provider.tsx
CHANGED
|
@@ -11,6 +11,40 @@ import { AppToaster, ColorModeProvider } from '#ui';
|
|
|
11
11
|
|
|
12
12
|
const config = defineConfig({
|
|
13
13
|
theme: {
|
|
14
|
+
recipes: {
|
|
15
|
+
button: {
|
|
16
|
+
variants: {
|
|
17
|
+
variant: {
|
|
18
|
+
outline: {
|
|
19
|
+
'--outline-color': 'colors.colorPalette.muted',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
badge: {
|
|
25
|
+
variants: {
|
|
26
|
+
variant: {
|
|
27
|
+
outline: {
|
|
28
|
+
'--outline-shadow': 'colors.colorPalette.muted',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
slotRecipes: {
|
|
35
|
+
tag: {
|
|
36
|
+
slots: defaultConfig.theme?.slotRecipes?.tag?.slots ?? [],
|
|
37
|
+
variants: {
|
|
38
|
+
variant: {
|
|
39
|
+
outline: {
|
|
40
|
+
root: {
|
|
41
|
+
'--outline-shadow': 'colors.colorPalette.muted',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
14
48
|
tokens: {
|
|
15
49
|
cursor: {
|
|
16
50
|
button: { value: 'pointer' },
|
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
BacktestJobRecord,
|
|
45
45
|
BacktestJobStatus,
|
|
46
46
|
} from '#app/lib/backtestJobs';
|
|
47
|
+
import { reachYandexMetrikaGoal } from '#app/lib/yandexMetrika';
|
|
47
48
|
|
|
48
49
|
const PERIOD_ITEMS = [
|
|
49
50
|
{ label: 'Days', value: 'days' },
|
|
@@ -84,6 +85,9 @@ type PeriodMode = 'days' | 'range';
|
|
|
84
85
|
type JobAction = 'pause' | 'stop' | 'resume' | 'cancel' | 'heartbeat';
|
|
85
86
|
|
|
86
87
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
88
|
+
const FIRST_BACKTEST_REPORTED_KEY = 'tradejs:analytics:first-backtest-reported';
|
|
89
|
+
const FIRST_BACKTEST_PENDING_JOB_KEY =
|
|
90
|
+
'tradejs:analytics:first-backtest-pending-job';
|
|
87
91
|
|
|
88
92
|
const toInputDate = (date: Date) => date.toISOString().slice(0, 10);
|
|
89
93
|
|
|
@@ -386,6 +390,31 @@ const BacktestRunPage = () => {
|
|
|
386
390
|
jobsRef.current = jobs;
|
|
387
391
|
}, [jobs]);
|
|
388
392
|
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (window.localStorage.getItem(FIRST_BACKTEST_REPORTED_KEY) === '1') {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const pendingJobId = window.localStorage.getItem(
|
|
399
|
+
FIRST_BACKTEST_PENDING_JOB_KEY,
|
|
400
|
+
);
|
|
401
|
+
if (!pendingJobId) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const pendingJob = jobs.find((job) => job.id === pendingJobId);
|
|
406
|
+
if (pendingJob?.status !== 'completed') {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!reachYandexMetrikaGoal('first_backtest')) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
window.localStorage.setItem(FIRST_BACKTEST_REPORTED_KEY, '1');
|
|
415
|
+
window.localStorage.removeItem(FIRST_BACKTEST_PENDING_JOB_KEY);
|
|
416
|
+
}, [jobs]);
|
|
417
|
+
|
|
389
418
|
useEffect(() => {
|
|
390
419
|
const timer = window.setInterval(() => {
|
|
391
420
|
const runningJobs = jobsRef.current.filter(
|
|
@@ -478,6 +507,9 @@ const BacktestRunPage = () => {
|
|
|
478
507
|
try {
|
|
479
508
|
const job = await startBacktestRun(payload);
|
|
480
509
|
setJobs((currentJobs) => mergeJob(currentJobs, job));
|
|
510
|
+
if (window.localStorage.getItem(FIRST_BACKTEST_REPORTED_KEY) !== '1') {
|
|
511
|
+
window.localStorage.setItem(FIRST_BACKTEST_PENDING_JOB_KEY, job.id);
|
|
512
|
+
}
|
|
481
513
|
toaster.success({
|
|
482
514
|
title: 'Backtest started',
|
|
483
515
|
description: getJobTitle(job),
|
|
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|
|
5
5
|
import Image from 'next/image';
|
|
6
6
|
import { signIn } from 'next-auth/react';
|
|
7
7
|
import { Box, Button, Field, Flex, Input, Stack, Text } from '@chakra-ui/react';
|
|
8
|
+
import { reachYandexMetrikaGoal } from '#app/lib/yandexMetrika';
|
|
8
9
|
|
|
9
10
|
const FIRST_DASHBOARD_PATH = '/routes/dashboard/coinbase/crypto/BTCUSDT/15';
|
|
10
11
|
|
|
@@ -64,6 +65,8 @@ const Install = () => {
|
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
reachYandexMetrikaGoal('scaffold_success');
|
|
69
|
+
|
|
67
70
|
const result = await signIn('credentials', {
|
|
68
71
|
redirect: false,
|
|
69
72
|
username: 'root',
|