@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 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
- requires `@tradejs/app@1.0.10` or newer.
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.6",
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.6",
55
- "@tradejs/core": "^2.0.6",
56
- "@tradejs/indicators": "^2.0.6",
57
- "@tradejs/infra": "^2.0.6",
58
- "@tradejs/node": "^2.0.6",
59
- "@tradejs/types": "^2.0.6",
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
- type ClosedPnlRecordWithOrderLinkId = Awaited<
47
- ReturnType<NonNullable<Connector['getClosedPnl']>>
48
- >[number] & {
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<PositionPnlSnapshot[]> => {
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 await connector.getOpenPositionPnl();
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 connector = await (connectorCreator as ConnectorCreator)({
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
- openPositions,
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 scopedTrades = relevantTrades.filter((trade) =>
592
- Boolean(trade.accountId || trade.deploymentId),
409
+ const syncableTrades = relevantTrades.filter((trade) =>
410
+ isRuntimeTradeInConnectorScope(trade, connector),
593
411
  );
594
- const defaultAccountTrades = relevantTrades.filter(
595
- (trade) => !trade.accountId && !trade.deploymentId,
412
+ const unsyncedTrades = relevantTrades.filter(
413
+ (trade) => !isRuntimeTradeInConnectorScope(trade, connector),
596
414
  );
597
- const syncedDefaultAccountTrades = await syncRuntimeTrades({
415
+ const syncedConnectorTrades = await syncRuntimeTrades({
598
416
  userName,
599
- trades: defaultAccountTrades,
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 = [...scopedTrades, ...syncedDefaultAccountTrades];
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,
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ EntryAnnotationExtendData,
2
3
  EntryLineExtendData,
3
4
  EntryPointsExtendData,
4
5
  EntryZoneExtendData,
@@ -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
- <Toast.Title>{toast.title}</Toast.Title>
36
- {toast.description ? (
37
- <Toast.Description>{toast.description}</Toast.Description>
38
- ) : null}
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
  )}
@@ -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
+ };
@@ -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',