@tradejs/app 2.0.6 → 2.0.7

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.7",
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.7",
55
+ "@tradejs/core": "^2.0.7",
56
+ "@tradejs/indicators": "^2.0.7",
57
+ "@tradejs/infra": "^2.0.7",
58
+ "@tradejs/node": "^2.0.7",
59
+ "@tradejs/types": "^2.0.7",
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,14 @@ 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
18
  ConnectorCreator,
22
- PositionPnlSnapshot,
23
19
  RuntimeTradeRecord,
24
20
  Interval,
25
21
  StrategyConfig,
@@ -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,207 +315,25 @@ 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 [];
337
- }
338
- };
339
-
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);
335
+ return { positions: [], reliable: false };
523
336
  }
524
-
525
- return syncedTrades;
526
337
  };
527
338
 
528
339
  export const GET = async (request: NextRequest) => {
@@ -559,7 +370,7 @@ export const GET = async (request: NextRequest) => {
559
370
  activeOrderIds,
560
371
  closedPnlRows,
561
372
  entryRows,
562
- openPositions,
373
+ openPositionsSnapshot,
563
374
  runtimeDeployments,
564
375
  tradingAccounts,
565
376
  ] = await Promise.all([
@@ -588,20 +399,22 @@ export const GET = async (request: NextRequest) => {
588
399
  startTime,
589
400
  activeOrderIds,
590
401
  );
591
- const scopedTrades = relevantTrades.filter((trade) =>
592
- Boolean(trade.accountId || trade.deploymentId),
402
+ const syncableTrades = relevantTrades.filter((trade) =>
403
+ isRuntimeTradeInConnectorScope(trade, connector),
593
404
  );
594
- const defaultAccountTrades = relevantTrades.filter(
595
- (trade) => !trade.accountId && !trade.deploymentId,
405
+ const unsyncedTrades = relevantTrades.filter(
406
+ (trade) => !isRuntimeTradeInConnectorScope(trade, connector),
596
407
  );
597
- const syncedDefaultAccountTrades = await syncRuntimeTrades({
408
+ const syncedConnectorTrades = await syncRuntimeTrades({
598
409
  userName,
599
- trades: defaultAccountTrades,
410
+ connector,
411
+ trades: syncableTrades,
600
412
  endTime,
601
- openPositions,
413
+ openPositions: openPositionsSnapshot.positions,
414
+ openPositionsReliable: openPositionsSnapshot.reliable,
602
415
  closedPnlRows,
603
416
  });
604
- const syncedTrades = [...scopedTrades, ...syncedDefaultAccountTrades];
417
+ const syncedTrades = [...unsyncedTrades, ...syncedConnectorTrades];
605
418
  const fallbackStrategyNames = [
606
419
  ...new Set([
607
420
  ...runtimeStrategyConfigs.map(({ strategyName }) => strategyName),
@@ -611,7 +424,7 @@ export const GET = async (request: NextRequest) => {
611
424
  const fallbackTrades = buildExchangeFallbackRuntimeTrades({
612
425
  entryRows,
613
426
  closedPnlRows,
614
- openPositions,
427
+ openPositions: openPositionsSnapshot.positions,
615
428
  strategyNames: fallbackStrategyNames,
616
429
  existingTrades: syncedTrades,
617
430
  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,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>
@@ -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
+ };
@@ -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',