aiden-shared-calculations-unified 1.0.149 → 1.0.150
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/calculations/alerts/Behaviour.js +41 -66
- package/package.json +1 -1
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
* Uses Mahalanobis Distance to detect complex deviations in investor behavior.
|
|
4
4
|
* Features: Concentration (HHI), Martingale Propensity, Capacity Strain, Risk Score.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
6
|
class BehavioralAnomaly {
|
|
9
7
|
constructor() {
|
|
10
8
|
this.results = {};
|
|
@@ -17,9 +15,8 @@ class BehavioralAnomaly {
|
|
|
17
15
|
type: 'standard',
|
|
18
16
|
isHistorical: true,
|
|
19
17
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// and only fetches dates that actually exist, saving reads automatically.
|
|
18
|
+
// RESTORED: Request full 60-day history for both Portfolio and Rankings.
|
|
19
|
+
// CachedDataLoader handles this efficiently via reference pointers.
|
|
23
20
|
rootDataSeries: {
|
|
24
21
|
portfolio: 60,
|
|
25
22
|
rankings: 60
|
|
@@ -28,7 +25,6 @@ class BehavioralAnomaly {
|
|
|
28
25
|
rootDataDependencies: ['portfolio', 'rankings', 'history'],
|
|
29
26
|
userType: 'POPULAR_INVESTOR',
|
|
30
27
|
|
|
31
|
-
// Allows the computation to run even if today's specific root data is missing
|
|
32
28
|
canHaveMissingRoots: true,
|
|
33
29
|
isAlertComputation: true,
|
|
34
30
|
mandatoryRoots: ['portfolio', 'history'],
|
|
@@ -39,14 +35,21 @@ class BehavioralAnomaly {
|
|
|
39
35
|
|
|
40
36
|
// --- Helpers ---
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
findClosestData(dateStr, seriesMap, maxLookback = 5) {
|
|
39
|
+
if (!seriesMap) return null;
|
|
40
|
+
if (seriesMap[dateStr]) return seriesMap[dateStr];
|
|
41
|
+
|
|
42
|
+
let current = new Date(dateStr);
|
|
43
|
+
for (let i = 0; i < maxLookback; i++) {
|
|
44
|
+
current.setUTCDate(current.getUTCDate() - 1);
|
|
45
|
+
const dString = current.toISOString().slice(0, 10);
|
|
46
|
+
if (seriesMap[dString]) return seriesMap[dString];
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
calculateHHI(portfolio, math) {
|
|
47
52
|
if (!portfolio) return 0;
|
|
48
|
-
|
|
49
|
-
// [FIX] DataExtractor is available directly on the flattened math object
|
|
50
53
|
const { DataExtractor } = math;
|
|
51
54
|
const positions = DataExtractor.getPositions(portfolio, 'POPULAR_INVESTOR');
|
|
52
55
|
|
|
@@ -55,29 +58,22 @@ class BehavioralAnomaly {
|
|
|
55
58
|
let sumSquares = 0;
|
|
56
59
|
let totalValue = 0;
|
|
57
60
|
|
|
58
|
-
positions.forEach(p =>
|
|
59
|
-
const val = DataExtractor.getPositionValue(p);
|
|
60
|
-
totalValue += val;
|
|
61
|
-
});
|
|
61
|
+
positions.forEach(p => totalValue += DataExtractor.getPositionValue(p));
|
|
62
62
|
|
|
63
63
|
if (totalValue === 0) return 0;
|
|
64
64
|
|
|
65
65
|
positions.forEach(p => {
|
|
66
|
-
const
|
|
67
|
-
const weight = val / totalValue;
|
|
66
|
+
const weight = DataExtractor.getPositionValue(p) / totalValue;
|
|
68
67
|
sumSquares += (weight * weight);
|
|
69
68
|
});
|
|
70
69
|
|
|
71
70
|
return sumSquares;
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
/**
|
|
75
|
-
* Helper: Calculates Martingale Score (Doubling down after loss)
|
|
76
|
-
*/
|
|
77
73
|
calculateMartingaleScore(tradeHistory) {
|
|
78
74
|
if (!tradeHistory || tradeHistory.length < 2) return 0;
|
|
79
75
|
|
|
80
|
-
//
|
|
76
|
+
// Trades are already filtered by date in the main loop logic
|
|
81
77
|
const sorted = [...tradeHistory].sort((a, b) => new Date(a.CloseDateTime) - new Date(b.CloseDateTime));
|
|
82
78
|
const recentTrades = sorted.slice(-30);
|
|
83
79
|
|
|
@@ -100,27 +96,7 @@ class BehavioralAnomaly {
|
|
|
100
96
|
return lossEvents > 0 ? (martingaleResponses / lossEvents) : 0;
|
|
101
97
|
}
|
|
102
98
|
|
|
103
|
-
/**
|
|
104
|
-
* "Use Closest" Logic: Looks for recent valid data point
|
|
105
|
-
*/
|
|
106
|
-
findClosestData(dateStr, seriesMap, maxLookback = 5) {
|
|
107
|
-
if (!seriesMap) return null;
|
|
108
|
-
if (seriesMap[dateStr]) return seriesMap[dateStr];
|
|
109
|
-
|
|
110
|
-
let current = new Date(dateStr);
|
|
111
|
-
for (let i = 0; i < maxLookback; i++) {
|
|
112
|
-
current.setUTCDate(current.getUTCDate() - 1);
|
|
113
|
-
const dString = current.toISOString().slice(0, 10);
|
|
114
|
-
if (seriesMap[dString]) return seriesMap[dString];
|
|
115
|
-
}
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* [FIX] Updated to accept 'math' context instead of 'layers'
|
|
121
|
-
*/
|
|
122
99
|
getDailyVector(portfolio, rankings, history, math) {
|
|
123
|
-
// [FIX] RankingsExtractor is available directly on the flattened math object
|
|
124
100
|
const { RankingsExtractor } = math;
|
|
125
101
|
|
|
126
102
|
const hhi = this.calculateHHI(portfolio, math);
|
|
@@ -140,79 +116,79 @@ class BehavioralAnomaly {
|
|
|
140
116
|
}
|
|
141
117
|
|
|
142
118
|
process(context) {
|
|
143
|
-
// [FIX] Use 'math' from context (ContextFactory flattens layers into 'math')
|
|
144
|
-
// [FIX] Access 'globalData' to retrieve series
|
|
145
119
|
const { math, globalData } = context;
|
|
146
120
|
const { LinearAlgebra } = math;
|
|
147
121
|
const userId = context.user.id;
|
|
148
122
|
|
|
149
|
-
// [FIX] Series data is located in globalData.series
|
|
150
123
|
const seriesData = globalData?.series?.root || {};
|
|
151
124
|
const portfolioSeries = seriesData.portfolio || {};
|
|
152
125
|
const rankingsSeries = seriesData.rankings || {};
|
|
153
126
|
|
|
154
|
-
//
|
|
155
|
-
|
|
127
|
+
// Full Trade History (Contains ALL time, so we must filter it)
|
|
128
|
+
const fullHistory = context.user.history || [];
|
|
129
|
+
|
|
156
130
|
const availableDates = Object.keys(portfolioSeries).sort();
|
|
157
131
|
const trainingVectors = [];
|
|
158
132
|
|
|
159
|
-
// 1. Build Baseline
|
|
133
|
+
// 1. Build Baseline
|
|
160
134
|
for (const date of availableDates) {
|
|
161
135
|
const datePortfolio = portfolioSeries[date][userId];
|
|
162
136
|
if (!datePortfolio) continue;
|
|
163
137
|
|
|
138
|
+
// CRITICAL FIX: Filter History to avoid "Time Travel"
|
|
139
|
+
// We only look at trades that had closed ON or BEFORE this specific loop date.
|
|
140
|
+
const loopDateObj = new Date(date);
|
|
141
|
+
const historyAsOfDate = fullHistory.filter(t => new Date(t.CloseDateTime) <= loopDateObj);
|
|
142
|
+
|
|
143
|
+
// Robust Lookup: Find ranking for this date (or closest recent date)
|
|
164
144
|
const rankingsSnapshot = this.findClosestData(date, rankingsSeries);
|
|
165
145
|
const userRanking = rankingsSnapshot ? rankingsSnapshot.find(r => String(r.CustomerId) === String(userId)) : null;
|
|
166
146
|
|
|
167
147
|
const vec = this.getDailyVector(
|
|
168
148
|
datePortfolio,
|
|
169
149
|
userRanking,
|
|
170
|
-
|
|
171
|
-
math
|
|
150
|
+
historyAsOfDate, // Pass filtered history
|
|
151
|
+
math
|
|
172
152
|
);
|
|
173
153
|
trainingVectors.push(vec);
|
|
174
154
|
}
|
|
175
155
|
|
|
176
|
-
// 2. Minimum Viable Baseline
|
|
177
|
-
// We requested 60 days, but we accept as few as 15 (approx 3 weeks)
|
|
178
|
-
// to avoid "2 month" delay for new users while ensuring statistical fairness.
|
|
156
|
+
// 2. Minimum Viable Baseline
|
|
179
157
|
const MIN_DATAPOINTS = 15;
|
|
180
|
-
|
|
181
158
|
if (trainingVectors.length < MIN_DATAPOINTS) {
|
|
182
159
|
this.results[userId] = {
|
|
183
160
|
status: 'INSUFFICIENT_BASELINE',
|
|
184
|
-
dataPoints: trainingVectors.length
|
|
185
|
-
required: MIN_DATAPOINTS,
|
|
186
|
-
info: 'Waiting for more history to generate fair alerts'
|
|
161
|
+
dataPoints: trainingVectors.length
|
|
187
162
|
};
|
|
188
163
|
return;
|
|
189
164
|
}
|
|
190
165
|
|
|
191
|
-
// 3. Compute Statistics
|
|
166
|
+
// 3. Compute Statistics (Covariance Matrix)
|
|
192
167
|
const stats = LinearAlgebra.covarianceMatrix(trainingVectors);
|
|
193
168
|
const inverseCov = LinearAlgebra.invertMatrix(stats.matrix);
|
|
194
169
|
|
|
195
|
-
// Singular Matrix Check
|
|
170
|
+
// Singular Matrix Check (If behavior is perfectly identical every day)
|
|
196
171
|
if (!inverseCov) {
|
|
197
|
-
this.results[userId] = { status: 'STABLE_STATE', info: 'Variance too low
|
|
172
|
+
this.results[userId] = { status: 'STABLE_STATE', info: 'Variance too low' };
|
|
198
173
|
return;
|
|
199
174
|
}
|
|
200
175
|
|
|
201
176
|
// 4. Compute Today's Vector
|
|
177
|
+
// We use the explicit 'today' injection, or fallback to the series
|
|
202
178
|
const todayRanking = context.user.rankEntry ||
|
|
203
179
|
(this.findClosestData(context.date.today, rankingsSeries) || [])
|
|
204
180
|
.find(r => String(r.CustomerId) === String(userId));
|
|
205
181
|
|
|
206
182
|
const todayVector = this.getDailyVector(
|
|
207
|
-
context.user.portfolio,
|
|
183
|
+
context.user.portfolio.today,
|
|
208
184
|
todayRanking,
|
|
209
|
-
|
|
210
|
-
math
|
|
185
|
+
fullHistory, // Today includes all history
|
|
186
|
+
math
|
|
211
187
|
);
|
|
212
188
|
|
|
213
189
|
// 5. Calculate Distance
|
|
214
190
|
const distance = LinearAlgebra.mahalanobisDistance(todayVector, stats.means, inverseCov);
|
|
215
|
-
const IS_ANOMALY = distance > 3.5;
|
|
191
|
+
const IS_ANOMALY = distance > 3.5;
|
|
216
192
|
|
|
217
193
|
if (IS_ANOMALY) {
|
|
218
194
|
const featureNames = ['Concentration (HHI)', 'Martingale Behavior', 'Capacity Strain', 'Risk Score'];
|
|
@@ -240,6 +216,7 @@ class BehavioralAnomaly {
|
|
|
240
216
|
}
|
|
241
217
|
};
|
|
242
218
|
|
|
219
|
+
// Add CID to index for downstream alert handlers
|
|
243
220
|
if (!this.results.cids) this.results.cids = [];
|
|
244
221
|
this.results.cids.push(userId);
|
|
245
222
|
} else {
|
|
@@ -249,8 +226,6 @@ class BehavioralAnomaly {
|
|
|
249
226
|
};
|
|
250
227
|
}
|
|
251
228
|
}
|
|
252
|
-
|
|
253
|
-
async getResult() { return this.results; }
|
|
254
229
|
}
|
|
255
230
|
|
|
256
231
|
module.exports = BehavioralAnomaly;
|