@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 +12 -1
- package/package.json +7 -7
- package/src/app/api/strategies/runtime/route.ts +26 -213
- 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/layout.tsx +38 -0
- package/src/app/lib/runtimeTradeSync.ts +271 -0
- package/src/app/lib/yandexMetrika.ts +30 -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.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.
|
|
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.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
|
-
|
|
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,207 +315,25 @@ 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 [];
|
|
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
|
-
|
|
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
|
|
592
|
-
|
|
402
|
+
const syncableTrades = relevantTrades.filter((trade) =>
|
|
403
|
+
isRuntimeTradeInConnectorScope(trade, connector),
|
|
593
404
|
);
|
|
594
|
-
const
|
|
595
|
-
(trade) => !trade
|
|
405
|
+
const unsyncedTrades = relevantTrades.filter(
|
|
406
|
+
(trade) => !isRuntimeTradeInConnectorScope(trade, connector),
|
|
596
407
|
);
|
|
597
|
-
const
|
|
408
|
+
const syncedConnectorTrades = await syncRuntimeTrades({
|
|
598
409
|
userName,
|
|
599
|
-
|
|
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 = [...
|
|
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,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createEntryAnnotationPointFigure } from '@tradejs/core/figures';
|
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>
|
|
@@ -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',
|