@tradejs/app 2.0.17 → 2.0.19

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.
Files changed (48) hide show
  1. package/package.json +14 -8
  2. package/src/app/actions/backtest.ts +2 -1
  3. package/src/app/actions/scanner.ts +2 -1
  4. package/src/app/api/backtest/files/route.ts +2 -1
  5. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +1 -1
  6. package/src/app/api/derivatives/[symbol]/[interval]/route.ts +1 -1
  7. package/src/app/api/derivatives/summary/route.ts +1 -1
  8. package/src/app/api/spread/[symbol]/[interval]/route.ts +1 -1
  9. package/src/app/api/spread/summary/route.ts +1 -1
  10. package/src/app/api/strategies/runtime/route.ts +4 -674
  11. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +1 -1
  12. package/src/app/api/user/runtime-deployments/route.ts +2 -2
  13. package/src/app/api/user/runtime-strategy-configs/route.ts +22 -212
  14. package/src/app/api/user/trading-accounts/[accountId]/route.ts +1 -1
  15. package/src/app/components/Backtest/TestList/index.tsx +1 -1
  16. package/src/app/components/Dashboard/KlineChart/figures/circle.ts +1 -1
  17. package/src/app/components/Dashboard/KlineChart/figures/diamond.ts +1 -1
  18. package/src/app/components/Dashboard/KlineChart/figures/label.ts +1 -1
  19. package/src/app/components/Dashboard/KlineChart/figures/rectangle.ts +1 -1
  20. package/src/app/components/Dashboard/KlineChart/figures/star.ts +1 -1
  21. package/src/app/components/Dashboard/KlineChart/index.tsx +2 -1
  22. package/src/app/components/Shared/Filters/Root/index.tsx +1 -1
  23. package/src/app/components/Shared/Filters/context.ts +1 -1
  24. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +81 -858
  25. package/src/app/components/Strategies/StrategyPerformanceCharts.tsx +419 -0
  26. package/src/app/components/Strategies/StrategySnapshotCard.tsx +122 -937
  27. package/src/app/components/UI/Segment/index.tsx +1 -1
  28. package/src/app/components/UI/Select/index.tsx +1 -1
  29. package/src/app/components/UI/SelectWithSearch/index.tsx +1 -1
  30. package/src/app/lib/backtestJobContracts.ts +67 -0
  31. package/src/app/lib/backtestJobProgress.ts +28 -0
  32. package/src/app/lib/backtestJobRequest.ts +107 -0
  33. package/src/app/lib/backtestJobs.ts +25 -257
  34. package/src/app/lib/runtimeDashboard.ts +684 -0
  35. package/src/app/lib/runtimeStrategies.ts +24 -454
  36. package/src/app/lib/runtimeStrategyConfigService.ts +279 -0
  37. package/src/app/lib/runtimeStrategyLineage.ts +264 -0
  38. package/src/app/lib/runtimeTradeReconciliation.ts +113 -0
  39. package/src/app/lib/runtimeTradeSync.ts +1 -1
  40. package/src/app/lib/strategyPerformance.ts +387 -0
  41. package/src/app/routes/dashboard/Dashboard.tsx +2 -6
  42. package/src/app/routes/derivatives/derivativesViewModel.ts +253 -0
  43. package/src/app/routes/derivatives/page.tsx +70 -262
  44. package/src/app/store/filters.ts +2 -1
  45. package/src/app/store/indicators.ts +2 -1
  46. package/src/app/store/tests.ts +1 -1
  47. package/src/app/store/tickers.ts +2 -1
  48. package/src/app/types/ui.ts +20 -0
@@ -0,0 +1,279 @@
1
+ import {
2
+ loadRuntimeStrategyConfigs,
3
+ saveRuntimeStrategyConfig,
4
+ type RuntimeStrategyConfigRecord,
5
+ } from '@tradejs/infra/runtimeStrategyConfigs';
6
+ import {
7
+ listTradingAccounts,
8
+ resolveTradingAccount,
9
+ } from '@tradejs/infra/tradingAccounts';
10
+ import { getAvailableStrategyNames } from '@tradejs/node/strategies';
11
+ import type { Interval, MarketUniverse, StrategyConfig } from '@tradejs/types';
12
+
13
+ export const RUNTIME_STRATEGY_INTERVALS = [
14
+ '1',
15
+ '3',
16
+ '5',
17
+ '15',
18
+ '30',
19
+ '60',
20
+ '120',
21
+ '240',
22
+ '360',
23
+ '720',
24
+ '1440',
25
+ ] as const;
26
+
27
+ const intervalSet = new Set<string>(RUNTIME_STRATEGY_INTERVALS);
28
+
29
+ export class RuntimeStrategyConfigServiceError extends Error {
30
+ constructor(
31
+ message: string,
32
+ readonly code: 'conflict' | 'not_found' | 'validation' = 'validation',
33
+ ) {
34
+ super(message);
35
+ this.name = 'RuntimeStrategyConfigServiceError';
36
+ }
37
+ }
38
+
39
+ type StoredRuntimeConfig = RuntimeStrategyConfigRecord & {
40
+ config: StrategyConfig;
41
+ };
42
+
43
+ export interface SaveRuntimeStrategyConfigInput {
44
+ strategyName?: unknown;
45
+ configId?: unknown;
46
+ interval?: unknown;
47
+ universe?: unknown;
48
+ accountId?: unknown;
49
+ enabled?: unknown;
50
+ parameters?: unknown;
51
+ }
52
+
53
+ const loadConfigs = async (userName: string): Promise<StoredRuntimeConfig[]> =>
54
+ (await loadRuntimeStrategyConfigs(userName)).map(
55
+ ({ strategyConfig, ...record }) => ({
56
+ ...record,
57
+ strategyConfig,
58
+ config: strategyConfig,
59
+ }),
60
+ );
61
+
62
+ const normalizeConfigId = (value: unknown) => {
63
+ const configId = String(value ?? '').trim();
64
+ if (!configId)
65
+ throw new RuntimeStrategyConfigServiceError('Config id is required');
66
+ if (!/^[a-zA-Z0-9_-]+$/.test(configId)) {
67
+ throw new RuntimeStrategyConfigServiceError(
68
+ 'Config id may contain only letters, numbers, _ and -',
69
+ );
70
+ }
71
+ if (configId === 'results') {
72
+ throw new RuntimeStrategyConfigServiceError(
73
+ 'Config id "results" is reserved',
74
+ );
75
+ }
76
+ return configId;
77
+ };
78
+
79
+ const normalizeUniverse = (value: unknown): MarketUniverse =>
80
+ value === 'tradfi' ? 'tradfi' : 'crypto';
81
+
82
+ const normalizeInterval = (value: unknown): Interval => {
83
+ const interval = String(value ?? '15');
84
+ if (!intervalSet.has(interval)) {
85
+ throw new RuntimeStrategyConfigServiceError(
86
+ `Unsupported timeframe: ${interval}`,
87
+ );
88
+ }
89
+ return interval as Interval;
90
+ };
91
+
92
+ const normalizeAccountId = (value: unknown) => {
93
+ const accountId = String(value ?? '').trim();
94
+ return accountId || undefined;
95
+ };
96
+
97
+ const resolveEffectiveAccountId = async ({
98
+ userName,
99
+ config,
100
+ }: {
101
+ userName: string;
102
+ config: StrategyConfig;
103
+ }) => {
104
+ const universe = normalizeUniverse(config.UNIVERSE);
105
+ const account = await resolveTradingAccount({
106
+ userName,
107
+ accountId: normalizeAccountId(config.ACCOUNT_ID),
108
+ provider: 'bybit',
109
+ universe,
110
+ });
111
+ return account?.id ?? null;
112
+ };
113
+
114
+ const assertNoEnabledAccountConflict = async ({
115
+ userName,
116
+ strategyName,
117
+ configId,
118
+ config,
119
+ existingConfigs,
120
+ }: {
121
+ userName: string;
122
+ strategyName: string;
123
+ configId: string;
124
+ config: StrategyConfig;
125
+ existingConfigs: StoredRuntimeConfig[];
126
+ }) => {
127
+ if (config.ENABLE === false) return;
128
+
129
+ const accountId = await resolveEffectiveAccountId({ userName, config });
130
+ if (!accountId) {
131
+ throw new RuntimeStrategyConfigServiceError(
132
+ `No enabled Bybit account supports ${normalizeUniverse(config.UNIVERSE)}. Connect an account or save this config as disabled.`,
133
+ );
134
+ }
135
+
136
+ for (const candidate of existingConfigs) {
137
+ if (
138
+ candidate.strategyName !== strategyName ||
139
+ candidate.configId === configId ||
140
+ candidate.config.ENABLE === false
141
+ ) {
142
+ continue;
143
+ }
144
+
145
+ const candidateAccountId = await resolveEffectiveAccountId({
146
+ userName,
147
+ config: candidate.config,
148
+ });
149
+ if (candidateAccountId === accountId) {
150
+ throw new RuntimeStrategyConfigServiceError(
151
+ `${strategyName} config "${candidate.configId}" already uses account "${accountId}". One strategy can run only once per account.`,
152
+ 'conflict',
153
+ );
154
+ }
155
+ }
156
+ };
157
+
158
+ const toResponseConfig = async (
159
+ userName: string,
160
+ row: StoredRuntimeConfig,
161
+ ) => ({
162
+ strategyName: row.strategyName,
163
+ configId: row.configId,
164
+ interval: normalizeInterval(row.config.INTERVAL),
165
+ universe: normalizeUniverse(row.config.UNIVERSE),
166
+ accountId: normalizeAccountId(row.config.ACCOUNT_ID) ?? null,
167
+ effectiveAccountId: await resolveEffectiveAccountId({
168
+ userName,
169
+ config: row.config,
170
+ }).catch(() => null),
171
+ enabled: row.config.ENABLE !== false,
172
+ config: row.config,
173
+ });
174
+
175
+ export const getRuntimeStrategyConfigOptions = async ({
176
+ userName,
177
+ projectRoot,
178
+ }: {
179
+ userName: string;
180
+ projectRoot: string;
181
+ }) => {
182
+ const [configs, strategyNames, accounts] = await Promise.all([
183
+ loadConfigs(userName),
184
+ getAvailableStrategyNames(projectRoot),
185
+ listTradingAccounts(userName),
186
+ ]);
187
+
188
+ return {
189
+ configs: await Promise.all(
190
+ configs.map((row) => toResponseConfig(userName, row)),
191
+ ),
192
+ strategyNames,
193
+ accounts: accounts.map(
194
+ ({ apiKey: _apiKey, apiSecret: _apiSecret, ...account }) => account,
195
+ ),
196
+ intervals: [...RUNTIME_STRATEGY_INTERVALS],
197
+ };
198
+ };
199
+
200
+ export const saveRuntimeStrategyConfigForUser = async ({
201
+ userName,
202
+ projectRoot,
203
+ input,
204
+ editing,
205
+ }: {
206
+ userName: string;
207
+ projectRoot: string;
208
+ input: SaveRuntimeStrategyConfigInput;
209
+ editing: boolean;
210
+ }) => {
211
+ const strategyName = String(input.strategyName ?? '').trim();
212
+ const configId = normalizeConfigId(input.configId);
213
+ const availableStrategies = await getAvailableStrategyNames(projectRoot);
214
+ if (!strategyName || !availableStrategies.includes(strategyName)) {
215
+ throw new RuntimeStrategyConfigServiceError(
216
+ `Unknown strategy: ${strategyName || '(empty)'}`,
217
+ );
218
+ }
219
+
220
+ const existingConfigs = await loadConfigs(userName);
221
+ const existing = existingConfigs.find(
222
+ (row) => row.strategyName === strategyName && row.configId === configId,
223
+ );
224
+ if (editing && !existing) {
225
+ throw new RuntimeStrategyConfigServiceError(
226
+ 'Runtime strategy config not found',
227
+ 'not_found',
228
+ );
229
+ }
230
+ if (!editing && existing) {
231
+ throw new RuntimeStrategyConfigServiceError(
232
+ 'Runtime strategy config already exists',
233
+ 'conflict',
234
+ );
235
+ }
236
+
237
+ if (
238
+ !input.parameters ||
239
+ typeof input.parameters !== 'object' ||
240
+ Array.isArray(input.parameters)
241
+ ) {
242
+ throw new RuntimeStrategyConfigServiceError(
243
+ 'Strategy parameters must be a JSON object',
244
+ );
245
+ }
246
+
247
+ const interval = normalizeInterval(input.interval);
248
+ const universe = normalizeUniverse(input.universe);
249
+ const accountId = normalizeAccountId(input.accountId);
250
+ const config: StrategyConfig = {
251
+ ...(input.parameters as StrategyConfig),
252
+ ENABLE: input.enabled !== false,
253
+ INTERVAL: interval,
254
+ UNIVERSE: universe,
255
+ };
256
+ if (accountId) config.ACCOUNT_ID = accountId;
257
+ else delete config.ACCOUNT_ID;
258
+
259
+ await assertNoEnabledAccountConflict({
260
+ userName,
261
+ strategyName,
262
+ configId,
263
+ config,
264
+ existingConfigs,
265
+ });
266
+ const saved = await saveRuntimeStrategyConfig({
267
+ userName,
268
+ strategyName,
269
+ configId,
270
+ strategyConfig: config,
271
+ });
272
+
273
+ return {
274
+ config: await toResponseConfig(userName, {
275
+ ...saved,
276
+ config,
277
+ }),
278
+ };
279
+ };
@@ -0,0 +1,264 @@
1
+ import type {
2
+ MarketUniverse,
3
+ RuntimeLineage,
4
+ RuntimeTradeRecord,
5
+ } from '@tradejs/types';
6
+
7
+ export interface RuntimeStrategyLineageScope {
8
+ strategy: string;
9
+ symbol: string;
10
+ runtimeConfigId?: string;
11
+ lineage: RuntimeLineage & { maxLossValue?: number | null };
12
+ firstTimestamp: number;
13
+ lastTimestamp: number;
14
+ }
15
+
16
+ export interface RuntimeStrategyAiGateChange {
17
+ timestamp: number;
18
+ previousFingerprint: string;
19
+ fingerprint: string;
20
+ }
21
+
22
+ export interface RuntimeStrategyMaxLossValueChange {
23
+ timestamp: number;
24
+ previousValue: number;
25
+ value: number;
26
+ }
27
+
28
+ export interface RuntimeStrategyMaxLossValueTimeline {
29
+ observedFrom: number | null;
30
+ initialValue: number | null;
31
+ changes: RuntimeStrategyMaxLossValueChange[];
32
+ }
33
+
34
+ export interface RuntimeStrategyAccountScope {
35
+ strategyName: string;
36
+ configId: string;
37
+ universe: MarketUniverse;
38
+ accountId?: string;
39
+ }
40
+
41
+ export const buildRuntimeStrategyIdentityKey = ({
42
+ strategyName,
43
+ configId,
44
+ universe,
45
+ accountId,
46
+ deploymentId,
47
+ policyProfileId,
48
+ }: {
49
+ strategyName: string;
50
+ configId?: string;
51
+ universe?: MarketUniverse;
52
+ accountId?: string;
53
+ deploymentId?: string;
54
+ policyProfileId?: string;
55
+ }) =>
56
+ [
57
+ strategyName,
58
+ configId ?? 'config',
59
+ universe ?? 'crypto',
60
+ accountId ?? 'default',
61
+ deploymentId ?? 'default',
62
+ policyProfileId ?? 'default',
63
+ ].join(':');
64
+
65
+ export const assignLegacyRuntimeTradeAccountScopes = (
66
+ trades: RuntimeTradeRecord[],
67
+ scopes: RuntimeStrategyAccountScope[],
68
+ ): RuntimeTradeRecord[] =>
69
+ trades.map((trade) => {
70
+ if (trade.accountId || trade.deploymentId) return trade;
71
+ const matchingAccountIds = new Set(
72
+ scopes
73
+ .filter(
74
+ (scope) =>
75
+ scope.strategyName === trade.strategy &&
76
+ scope.configId === (trade.runtimeConfigId ?? 'config') &&
77
+ scope.universe === (trade.universe ?? 'crypto'),
78
+ )
79
+ .map((scope) => scope.accountId)
80
+ .filter((accountId): accountId is string => Boolean(accountId)),
81
+ );
82
+ return matchingAccountIds.size === 1
83
+ ? { ...trade, accountId: [...matchingAccountIds][0] }
84
+ : trade;
85
+ });
86
+
87
+ export const getRuntimeStrategyAiGateObservedFrom = ({
88
+ scopes,
89
+ strategyName,
90
+ configId,
91
+ endTime,
92
+ }: {
93
+ scopes: RuntimeStrategyLineageScope[];
94
+ strategyName: string;
95
+ configId?: string;
96
+ endTime: number;
97
+ }) => {
98
+ const normalizedConfigId = configId ?? 'config';
99
+ let observedFrom: number | null = null;
100
+ for (const scope of scopes) {
101
+ if (
102
+ scope.strategy !== strategyName ||
103
+ (scope.runtimeConfigId ?? 'config') !== normalizedConfigId ||
104
+ scope.firstTimestamp > endTime
105
+ ) {
106
+ continue;
107
+ }
108
+ observedFrom =
109
+ observedFrom == null
110
+ ? scope.firstTimestamp
111
+ : Math.min(observedFrom, scope.firstTimestamp);
112
+ }
113
+ return observedFrom;
114
+ };
115
+
116
+ export const buildRuntimeStrategyMaxLossValueTimeline = ({
117
+ scopes,
118
+ strategyName,
119
+ configId,
120
+ startTime,
121
+ endTime,
122
+ }: {
123
+ scopes: RuntimeStrategyLineageScope[];
124
+ strategyName: string;
125
+ configId?: string;
126
+ startTime: number;
127
+ endTime: number;
128
+ }): RuntimeStrategyMaxLossValueTimeline => {
129
+ const normalizedConfigId = configId ?? 'config';
130
+ const observationsByTimestamp = new Map<
131
+ number,
132
+ { value: number; lastTimestamp: number }
133
+ >();
134
+ for (const scope of scopes) {
135
+ const value = scope.lineage.maxLossValue;
136
+ if (
137
+ scope.strategy !== strategyName ||
138
+ (scope.runtimeConfigId ?? 'config') !== normalizedConfigId ||
139
+ scope.firstTimestamp > endTime ||
140
+ typeof value !== 'number' ||
141
+ !Number.isFinite(value)
142
+ ) {
143
+ continue;
144
+ }
145
+ const existing = observationsByTimestamp.get(scope.firstTimestamp);
146
+ if (
147
+ !existing ||
148
+ scope.lastTimestamp > existing.lastTimestamp ||
149
+ (scope.lastTimestamp === existing.lastTimestamp && value > existing.value)
150
+ ) {
151
+ observationsByTimestamp.set(scope.firstTimestamp, {
152
+ value,
153
+ lastTimestamp: scope.lastTimestamp,
154
+ });
155
+ }
156
+ }
157
+
158
+ const changes: RuntimeStrategyMaxLossValueChange[] = [];
159
+ let observedFrom: number | null = null;
160
+ let initialValue: number | null = null;
161
+ let currentValue: number | null = null;
162
+ for (const [timestamp, observation] of [
163
+ ...observationsByTimestamp.entries(),
164
+ ].sort(([left], [right]) => left - right)) {
165
+ if (currentValue == null) {
166
+ observedFrom = timestamp;
167
+ initialValue = observation.value;
168
+ currentValue = observation.value;
169
+ continue;
170
+ }
171
+ if (observation.value === currentValue) continue;
172
+ if (timestamp >= startTime) {
173
+ changes.push({
174
+ timestamp,
175
+ previousValue: currentValue,
176
+ value: observation.value,
177
+ });
178
+ }
179
+ currentValue = observation.value;
180
+ }
181
+ return { observedFrom, initialValue, changes };
182
+ };
183
+
184
+ export const isRuntimeStrategyLineageScope = (
185
+ value: unknown,
186
+ ): value is RuntimeStrategyLineageScope => {
187
+ if (!value || typeof value !== 'object') return false;
188
+ const record = value as Record<string, unknown>;
189
+ const lineage = record.lineage as Record<string, unknown> | undefined;
190
+ return (
191
+ typeof record.strategy === 'string' &&
192
+ typeof record.symbol === 'string' &&
193
+ typeof record.firstTimestamp === 'number' &&
194
+ Number.isFinite(record.firstTimestamp) &&
195
+ typeof record.lastTimestamp === 'number' &&
196
+ Number.isFinite(record.lastTimestamp) &&
197
+ lineage != null &&
198
+ typeof lineage.gateFingerprint === 'string' &&
199
+ lineage.gateFingerprint.trim().length > 0
200
+ );
201
+ };
202
+
203
+ export const buildRuntimeStrategyAiGateChanges = ({
204
+ scopes,
205
+ strategyName,
206
+ configId,
207
+ startTime,
208
+ endTime,
209
+ }: {
210
+ scopes: RuntimeStrategyLineageScope[];
211
+ strategyName: string;
212
+ configId?: string;
213
+ startTime: number;
214
+ endTime: number;
215
+ }): RuntimeStrategyAiGateChange[] => {
216
+ const normalizedConfigId = configId ?? 'config';
217
+ const observationsByTimestamp = new Map<
218
+ number,
219
+ { fingerprint: string; lastTimestamp: number }
220
+ >();
221
+ for (const scope of scopes) {
222
+ if (
223
+ scope.strategy !== strategyName ||
224
+ (scope.runtimeConfigId ?? 'config') !== normalizedConfigId ||
225
+ scope.firstTimestamp > endTime
226
+ ) {
227
+ continue;
228
+ }
229
+ const fingerprint = scope.lineage.gateFingerprint.trim();
230
+ const existing = observationsByTimestamp.get(scope.firstTimestamp);
231
+ if (
232
+ !existing ||
233
+ scope.lastTimestamp > existing.lastTimestamp ||
234
+ (scope.lastTimestamp === existing.lastTimestamp &&
235
+ fingerprint > existing.fingerprint)
236
+ ) {
237
+ observationsByTimestamp.set(scope.firstTimestamp, {
238
+ fingerprint,
239
+ lastTimestamp: scope.lastTimestamp,
240
+ });
241
+ }
242
+ }
243
+
244
+ const changes: RuntimeStrategyAiGateChange[] = [];
245
+ let currentFingerprint: string | null = null;
246
+ for (const [timestamp, observation] of [
247
+ ...observationsByTimestamp.entries(),
248
+ ].sort(([left], [right]) => left - right)) {
249
+ if (currentFingerprint == null) {
250
+ currentFingerprint = observation.fingerprint;
251
+ continue;
252
+ }
253
+ if (observation.fingerprint === currentFingerprint) continue;
254
+ if (timestamp >= startTime) {
255
+ changes.push({
256
+ timestamp,
257
+ previousFingerprint: currentFingerprint,
258
+ fingerprint: observation.fingerprint,
259
+ });
260
+ }
261
+ currentFingerprint = observation.fingerprint;
262
+ }
263
+ return changes;
264
+ };
@@ -0,0 +1,113 @@
1
+ import type { ClosedPnlRecord, RuntimeTradeRecord } from '@tradejs/types';
2
+
3
+ export type ClosedPnlRecordWithOrderLinkId = ClosedPnlRecord & {
4
+ direction?: RuntimeTradeRecord['direction'];
5
+ entryTimestamp?: number;
6
+ orderLinkId?: string;
7
+ };
8
+
9
+ const toNonEmptyString = (value: unknown) =>
10
+ typeof value === 'string' && value.trim() ? value.trim() : null;
11
+
12
+ const removeFromExactMaps = ({
13
+ exactByOrderLinkId,
14
+ exactByOrderId,
15
+ row,
16
+ }: {
17
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
18
+ exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
19
+ row: ClosedPnlRecordWithOrderLinkId;
20
+ }) => {
21
+ for (const [key, value] of exactByOrderLinkId) {
22
+ if (value === row) exactByOrderLinkId.delete(key);
23
+ }
24
+ for (const [key, value] of exactByOrderId) {
25
+ if (value === row) exactByOrderId.delete(key);
26
+ }
27
+ };
28
+
29
+ const removeFromSymbolBuckets = (
30
+ buckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>,
31
+ row: ClosedPnlRecordWithOrderLinkId,
32
+ ) => {
33
+ const rows = buckets.get(row.symbol);
34
+ const index = rows?.findIndex((candidate) => candidate === row) ?? -1;
35
+ if (index >= 0) rows?.splice(index, 1);
36
+ };
37
+
38
+ export const takeExactClosedPnlMatch = ({
39
+ exactByOrderLinkId,
40
+ exactByOrderId,
41
+ symbolBuckets,
42
+ orderLinkId,
43
+ orderId,
44
+ }: {
45
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
46
+ exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
47
+ symbolBuckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>;
48
+ orderLinkId?: string | null;
49
+ orderId?: string | null;
50
+ }) => {
51
+ const exactKeys: Array<
52
+ [Map<string, ClosedPnlRecordWithOrderLinkId>, string | null | undefined]
53
+ > = [
54
+ [exactByOrderLinkId, orderLinkId],
55
+ [exactByOrderId, orderId],
56
+ ];
57
+ for (const [bucket, key] of exactKeys) {
58
+ const normalizedKey = toNonEmptyString(key);
59
+ if (!normalizedKey) continue;
60
+ const exactMatch = bucket.get(normalizedKey);
61
+ if (!exactMatch) continue;
62
+ removeFromExactMaps({
63
+ exactByOrderLinkId,
64
+ exactByOrderId,
65
+ row: exactMatch,
66
+ });
67
+ removeFromSymbolBuckets(symbolBuckets, exactMatch);
68
+ return exactMatch;
69
+ }
70
+ return null;
71
+ };
72
+
73
+ export const takeClosedPnlMatch = ({
74
+ exactByOrderLinkId,
75
+ exactByOrderId = new Map<string, ClosedPnlRecordWithOrderLinkId>(),
76
+ symbolBuckets,
77
+ trade,
78
+ }: {
79
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
80
+ exactByOrderId?: Map<string, ClosedPnlRecordWithOrderLinkId>;
81
+ symbolBuckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>;
82
+ trade: RuntimeTradeRecord;
83
+ }) => {
84
+ const exactMatch = takeExactClosedPnlMatch({
85
+ exactByOrderLinkId,
86
+ exactByOrderId,
87
+ symbolBuckets,
88
+ orderLinkId: trade.orderId,
89
+ orderId: trade.orderId,
90
+ });
91
+ if (exactMatch) return exactMatch;
92
+
93
+ const rows = symbolBuckets.get(trade.symbol);
94
+ if (!rows?.length) return null;
95
+ const minimumClosedAt = trade.entryTimestamp - 5 * 60_000;
96
+ const matchIndex = rows.reduce((bestIndex, row, index) => {
97
+ if (
98
+ !Number.isFinite(row.closedAt) ||
99
+ row.closedAt < minimumClosedAt ||
100
+ (row.direction && row.direction !== trade.direction)
101
+ ) {
102
+ return bestIndex;
103
+ }
104
+ if (bestIndex < 0) return index;
105
+ return row.closedAt < rows[bestIndex].closedAt ? index : bestIndex;
106
+ }, -1);
107
+ if (matchIndex < 0) return null;
108
+ const [row] = rows.splice(matchIndex, 1);
109
+ if (row) {
110
+ removeFromExactMaps({ exactByOrderLinkId, exactByOrderId, row });
111
+ }
112
+ return row ?? null;
113
+ };
@@ -13,7 +13,7 @@ import type {
13
13
  PositionPnlSnapshot,
14
14
  RuntimeTradeRecord,
15
15
  } from '@tradejs/types';
16
- import { takeClosedPnlMatch } from './runtimeStrategies';
16
+ import { takeClosedPnlMatch } from './runtimeTradeReconciliation';
17
17
 
18
18
  export type ClosedPnlRecordWithOrderLinkId = ClosedPnlRecord & {
19
19
  orderLinkId?: string;