bulltrackers-module 1.0.778 → 1.0.779
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/functions/alert-system/helpers/alert_helpers.js +114 -90
- package/functions/alert-system/helpers/alert_manifest_loader.js +88 -99
- package/functions/alert-system/index.js +81 -138
- package/functions/alert-system/tests/stage1-alert-manifest.test.js +94 -0
- package/functions/alert-system/tests/stage2-alert-metadata.test.js +93 -0
- package/functions/alert-system/tests/stage3-alert-handler.test.js +79 -0
- package/functions/api-v2/helpers/data-fetchers/firestore.js +613 -478
- package/functions/api-v2/routes/popular_investors.js +7 -7
- package/functions/api-v2/routes/profile.js +2 -1
- package/functions/api-v2/tests/stage4-profile-paths.test.js +52 -0
- package/functions/api-v2/tests/stage5-aum-bigquery.test.js +81 -0
- package/functions/api-v2/tests/stage7-pi-page-views.test.js +55 -0
- package/functions/api-v2/tests/stage8-watchlist-membership.test.js +49 -0
- package/functions/api-v2/tests/stage9-user-alert-settings.test.js +81 -0
- package/functions/computation-system-v2/computations/BehavioralAnomaly.js +104 -81
- package/functions/computation-system-v2/computations/NewSectorExposure.js +7 -7
- package/functions/computation-system-v2/computations/NewSocialPost.js +6 -6
- package/functions/computation-system-v2/computations/PositionInvestedIncrease.js +11 -11
- package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +1 -1
- package/functions/computation-system-v2/config/bulltrackers.config.js +8 -0
- package/functions/computation-system-v2/framework/core/Manifest.js +1 -0
- package/functions/core/utils/bigquery_utils.js +32 -0
- package/package.json +1 -1
|
@@ -62,7 +62,7 @@ const AdvancedMath = {
|
|
|
62
62
|
// Calculate aggregate volatility across the CORE features (indices 0-5)
|
|
63
63
|
const coreIndices = [0, 1, 2, 3, 4, 5];
|
|
64
64
|
const volatilities = [];
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
for (const f of coreIndices) {
|
|
67
67
|
const values = recentVectors.map(v => v[f]);
|
|
68
68
|
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
@@ -70,7 +70,7 @@ const AdvancedMath = {
|
|
|
70
70
|
volatilities.push(Math.sqrt(variance));
|
|
71
71
|
}
|
|
72
72
|
const avgVolatility = volatilities.reduce((sum, v) => sum + v, 0) / volatilities.length;
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
if (avgVolatility < 0.3) return 'calm';
|
|
75
75
|
if (avgVolatility < 0.8) return 'active';
|
|
76
76
|
return 'stressed';
|
|
@@ -93,7 +93,7 @@ const FeatureExtractor = {
|
|
|
93
93
|
if (!ticker) return 'Unknown';
|
|
94
94
|
return sectorMap.get(ticker) || 'Unknown';
|
|
95
95
|
};
|
|
96
|
-
|
|
96
|
+
|
|
97
97
|
// 1. Sector Concentration (HHI)
|
|
98
98
|
const sectorExposure = {};
|
|
99
99
|
let totalInvestedPct = 0;
|
|
@@ -104,7 +104,7 @@ const FeatureExtractor = {
|
|
|
104
104
|
totalInvestedPct += val;
|
|
105
105
|
});
|
|
106
106
|
const sectorHHI = AdvancedMath.hhi(Object.values(sectorExposure));
|
|
107
|
-
|
|
107
|
+
|
|
108
108
|
// 2. Martingale/Distress Score
|
|
109
109
|
let martingaleScore = 0;
|
|
110
110
|
let martingaleCount = 0;
|
|
@@ -116,56 +116,56 @@ const FeatureExtractor = {
|
|
|
116
116
|
martingaleCount++;
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
|
-
|
|
119
|
+
|
|
120
120
|
// 3. Leverage Profile (Lazy Loaded)
|
|
121
121
|
const { avgLeverage, highLevCount } = FeatureExtractor._extractLeverageProfile(day.date, historyBlob);
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
// 4. Risk Score
|
|
124
124
|
const riskScore = rankingsData.RiskScore || 1;
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
// 5. Complexity
|
|
127
127
|
const complexity = positions.length;
|
|
128
|
-
|
|
128
|
+
|
|
129
129
|
// 6. Exposure
|
|
130
130
|
const exposure = totalInvestedPct;
|
|
131
|
-
|
|
131
|
+
|
|
132
132
|
// --- EXTENDED FEATURES (Full Spectrum) ---
|
|
133
|
-
|
|
133
|
+
|
|
134
134
|
// 7. Entropy
|
|
135
135
|
const positionSizes = positions.map(p => p.Invested || 0);
|
|
136
136
|
const portfolioEntropy = AdvancedMath.entropy(positionSizes);
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
// 8. Drawdown
|
|
139
139
|
const drawdownSeverity = Math.abs(rankingsData.PeakToValley || 0);
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
// 9. Win Rate Deviation
|
|
142
142
|
const winRateDeviation = Math.abs((rankingsData.WinRatio || 50) - 50);
|
|
143
|
-
|
|
143
|
+
|
|
144
144
|
// 10. Skewness
|
|
145
145
|
const sortedSizes = [...positionSizes].sort((a, b) => b - a);
|
|
146
146
|
const top3Share = sortedSizes.slice(0, 3).reduce((sum, v) => sum + v, 0);
|
|
147
147
|
const positionSkewness = totalInvestedPct > 0 ? top3Share / totalInvestedPct : 0;
|
|
148
|
-
|
|
148
|
+
|
|
149
149
|
// 11. Stress Ratio
|
|
150
150
|
const losingCount = positions.filter(p => (p.NetProfit || 0) < 0).length;
|
|
151
151
|
const stressRatio = positions.length > 0 ? losingCount / positions.length : 0;
|
|
152
|
-
|
|
152
|
+
|
|
153
153
|
// 12. Credit Imbalance
|
|
154
154
|
const realizedCredit = portfolio?.CreditByRealizedEquity || 0;
|
|
155
155
|
const unrealizedCredit = portfolio?.CreditByUnrealizedEquity || 0;
|
|
156
156
|
const creditImbalance = Math.abs(realizedCredit - unrealizedCredit);
|
|
157
|
-
|
|
157
|
+
|
|
158
158
|
// 13. High Lev Freq
|
|
159
159
|
const highLevFrequency = highLevCount / Math.max(1, positions.length);
|
|
160
|
-
|
|
160
|
+
|
|
161
161
|
// 14. Copier Momentum
|
|
162
162
|
const copiers = rankingsData.Copiers || 0;
|
|
163
163
|
const baselineCopiers = rankingsData.BaseLineCopiers || copiers;
|
|
164
164
|
const copierMomentum = baselineCopiers > 0 ? (copiers - baselineCopiers) / baselineCopiers : 0;
|
|
165
|
-
|
|
165
|
+
|
|
166
166
|
// 15. AUM Tier
|
|
167
167
|
const aumTier = (rankingsData.AUMTier || 0);
|
|
168
|
-
|
|
168
|
+
|
|
169
169
|
// 16. Exposure Velocity
|
|
170
170
|
let exposureVelocity = 0;
|
|
171
171
|
if (prevDay && prevDay.portfolio) {
|
|
@@ -173,7 +173,7 @@ const FeatureExtractor = {
|
|
|
173
173
|
const prevExposure = prevPositions.reduce((sum, p) => sum + (p.Invested || 0), 0);
|
|
174
174
|
exposureVelocity = exposure - prevExposure;
|
|
175
175
|
}
|
|
176
|
-
|
|
176
|
+
|
|
177
177
|
// 17. Risk Acceleration
|
|
178
178
|
let riskAcceleration = 0;
|
|
179
179
|
if (prevDay && prevDay.rankings && prevPrevDay && prevPrevDay.rankings) {
|
|
@@ -182,33 +182,33 @@ const FeatureExtractor = {
|
|
|
182
182
|
const r2 = riskScore;
|
|
183
183
|
riskAcceleration = AdvancedMath.acceleration(r2, r1, r0, 1);
|
|
184
184
|
}
|
|
185
|
-
|
|
185
|
+
|
|
186
186
|
// 18. Behavioral Momentum
|
|
187
187
|
const behavioralMomentum = Math.abs(exposureVelocity) + Math.abs(riskAcceleration);
|
|
188
|
-
|
|
188
|
+
|
|
189
189
|
return {
|
|
190
190
|
vector: [
|
|
191
191
|
// CORE (0-5)
|
|
192
|
-
sectorHHI,
|
|
193
|
-
Math.log(martingaleScore + 1),
|
|
194
|
-
avgLeverage,
|
|
195
|
-
riskScore,
|
|
196
|
-
complexity,
|
|
197
|
-
exposure,
|
|
198
|
-
|
|
192
|
+
sectorHHI,
|
|
193
|
+
Math.log(martingaleScore + 1),
|
|
194
|
+
avgLeverage,
|
|
195
|
+
riskScore,
|
|
196
|
+
complexity,
|
|
197
|
+
exposure,
|
|
198
|
+
|
|
199
199
|
// EXTENDED (6-17)
|
|
200
|
-
portfolioEntropy,
|
|
201
|
-
drawdownSeverity,
|
|
202
|
-
winRateDeviation,
|
|
203
|
-
positionSkewness,
|
|
204
|
-
stressRatio,
|
|
205
|
-
creditImbalance,
|
|
206
|
-
highLevFrequency,
|
|
207
|
-
copierMomentum,
|
|
208
|
-
aumTier,
|
|
209
|
-
exposureVelocity,
|
|
210
|
-
riskAcceleration,
|
|
211
|
-
behavioralMomentum
|
|
200
|
+
portfolioEntropy,
|
|
201
|
+
drawdownSeverity,
|
|
202
|
+
winRateDeviation,
|
|
203
|
+
positionSkewness,
|
|
204
|
+
stressRatio,
|
|
205
|
+
creditImbalance,
|
|
206
|
+
highLevFrequency,
|
|
207
|
+
copierMomentum,
|
|
208
|
+
aumTier,
|
|
209
|
+
exposureVelocity,
|
|
210
|
+
riskAcceleration,
|
|
211
|
+
behavioralMomentum
|
|
212
212
|
],
|
|
213
213
|
metadata: {
|
|
214
214
|
martingaleCount,
|
|
@@ -270,7 +270,7 @@ const AnomalyEngine = {
|
|
|
270
270
|
// 5. VELOCITY
|
|
271
271
|
const velocityScore = (Math.abs(zVector[15]) + Math.abs(zVector[16]) + Math.abs(zVector[17])) / 3;
|
|
272
272
|
|
|
273
|
-
const ensembleScore =
|
|
273
|
+
const ensembleScore =
|
|
274
274
|
0.40 * mahalanobisScore +
|
|
275
275
|
0.30 * percentileScore +
|
|
276
276
|
0.15 * regimeScore +
|
|
@@ -285,7 +285,7 @@ const AnomalyEngine = {
|
|
|
285
285
|
velocity: velocityScore
|
|
286
286
|
},
|
|
287
287
|
// EXPORT Z-SCORES FOR INTERPRETER
|
|
288
|
-
zScores: zVector
|
|
288
|
+
zScores: zVector
|
|
289
289
|
};
|
|
290
290
|
},
|
|
291
291
|
|
|
@@ -302,7 +302,7 @@ const AnomalyEngine = {
|
|
|
302
302
|
for (const v of vectors) for (let i = 0; i < dim; i++) stdDevs[i] += Math.pow(v[i] - means[i], 2);
|
|
303
303
|
for (let i = 0; i < dim; i++) {
|
|
304
304
|
stdDevs[i] = Math.sqrt(stdDevs[i] / n);
|
|
305
|
-
if (stdDevs[i] === 0) stdDevs[i] = 1;
|
|
305
|
+
if (stdDevs[i] === 0) stdDevs[i] = 1;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
const zVector = todayVec.map((v, i) => (v - means[i]) / stdDevs[i]);
|
|
@@ -315,9 +315,9 @@ const AnomalyEngine = {
|
|
|
315
315
|
const means = new Array(todayZVec.length).fill(0);
|
|
316
316
|
const cov = AnomalyEngine._covariance(histZVectors, means);
|
|
317
317
|
const invCov = AnomalyEngine._invert(cov);
|
|
318
|
-
|
|
318
|
+
|
|
319
319
|
if (!invCov) return 0;
|
|
320
|
-
|
|
320
|
+
|
|
321
321
|
let sum = 0;
|
|
322
322
|
for (let i = 0; i < todayZVec.length; i++) {
|
|
323
323
|
for (let j = 0; j < todayZVec.length; j++) {
|
|
@@ -385,14 +385,14 @@ const AnomalyEngine = {
|
|
|
385
385
|
// =============================================================================
|
|
386
386
|
const PredictiveEngine = {
|
|
387
387
|
forecastRisk: (todayFeatures, historicalFeatures, anomalyScore) => {
|
|
388
|
-
const momentum = todayFeatures.vector[17];
|
|
388
|
+
const momentum = todayFeatures.vector[17];
|
|
389
389
|
const riskAccel = todayFeatures.vector[16];
|
|
390
|
-
|
|
391
|
-
let baseProbability = 1 / (1 + Math.exp(-(anomalyScore - 3)));
|
|
392
|
-
|
|
390
|
+
|
|
391
|
+
let baseProbability = 1 / (1 + Math.exp(-(anomalyScore - 3)));
|
|
392
|
+
|
|
393
393
|
if (momentum > 1.0) baseProbability = Math.min(0.99, baseProbability * 1.2);
|
|
394
394
|
if (riskAccel > 1.0) baseProbability = Math.min(0.99, baseProbability * 1.15);
|
|
395
|
-
|
|
395
|
+
|
|
396
396
|
return {
|
|
397
397
|
probability7d: baseProbability,
|
|
398
398
|
confidence: historicalFeatures.length >= 25 ? 'high' : 'medium',
|
|
@@ -405,10 +405,10 @@ const SemanticInterpreter = {
|
|
|
405
405
|
interpret: (scores, features, prediction, regime, featureNames) => {
|
|
406
406
|
// ROBUST LOGIC: Use the Z-Scores passed from AnomalyEngine.
|
|
407
407
|
const zScores = scores.zScores || [];
|
|
408
|
-
|
|
408
|
+
|
|
409
409
|
let maxIdx = 0;
|
|
410
410
|
let maxZ = 0;
|
|
411
|
-
|
|
411
|
+
|
|
412
412
|
// Find feature with highest ABSOLUTE Z-score (Statistically most significant)
|
|
413
413
|
zScores.forEach((z, i) => {
|
|
414
414
|
if (Math.abs(z) > maxZ) {
|
|
@@ -416,26 +416,26 @@ const SemanticInterpreter = {
|
|
|
416
416
|
maxIdx = i;
|
|
417
417
|
}
|
|
418
418
|
});
|
|
419
|
-
|
|
419
|
+
|
|
420
420
|
const primaryDriver = featureNames[maxIdx];
|
|
421
421
|
const driverZ = zScores[maxIdx];
|
|
422
422
|
const rawValue = features.vector[maxIdx];
|
|
423
|
-
|
|
423
|
+
|
|
424
424
|
let description = '';
|
|
425
425
|
let severity = 'low';
|
|
426
|
-
|
|
426
|
+
|
|
427
427
|
if (scores.overall > 5.0) { severity = 'critical'; description = `🚨 CRITICAL: Severe anomaly in ${regime} regime. `; }
|
|
428
428
|
else if (scores.overall > 4.0) { severity = 'high'; description = `⚠️ HIGH RISK: Significant deviation. `; }
|
|
429
429
|
else if (scores.overall > 3.0) { severity = 'medium'; description = `⚡ MODERATE: Unusual pattern. `; }
|
|
430
430
|
else { description = `ℹ️ NOTICE: Minor shift. `; }
|
|
431
|
-
|
|
431
|
+
|
|
432
432
|
const direction = driverZ > 0 ? "increased" : "decreased";
|
|
433
|
-
|
|
433
|
+
|
|
434
434
|
description += `Driven by ${primaryDriver}. `;
|
|
435
435
|
description += `Value ${direction} to ${rawValue.toFixed(2)} (${driverZ > 0 ? '+' : ''}${maxZ.toFixed(1)}σ). `;
|
|
436
|
-
|
|
437
|
-
if (prediction.probability7d > 0.7) description += `High escalation risk (${(prediction.probability7d*100).toFixed(0)}%).`;
|
|
438
|
-
|
|
436
|
+
|
|
437
|
+
if (prediction.probability7d > 0.7) description += `High escalation risk (${(prediction.probability7d * 100).toFixed(0)}%).`;
|
|
438
|
+
|
|
439
439
|
return {
|
|
440
440
|
description,
|
|
441
441
|
severity,
|
|
@@ -458,17 +458,17 @@ class BehavioralAnomaly extends Computation {
|
|
|
458
458
|
type: 'per-entity',
|
|
459
459
|
category: 'alerts',
|
|
460
460
|
isHistorical: true,
|
|
461
|
-
|
|
461
|
+
|
|
462
462
|
requires: {
|
|
463
463
|
// COST CONTROL: 30-day limit
|
|
464
464
|
'portfolio_snapshots': {
|
|
465
|
-
lookback: 30,
|
|
465
|
+
lookback: 30,
|
|
466
466
|
mandatory: true,
|
|
467
467
|
fields: ['user_id', 'portfolio_data', 'date']
|
|
468
468
|
},
|
|
469
469
|
'pi_rankings': {
|
|
470
470
|
lookback: 30,
|
|
471
|
-
mandatory: true,
|
|
471
|
+
mandatory: true,
|
|
472
472
|
fields: ['pi_id', 'rankings_data', 'date']
|
|
473
473
|
},
|
|
474
474
|
'trade_history_snapshots': {
|
|
@@ -483,19 +483,42 @@ class BehavioralAnomaly extends Computation {
|
|
|
483
483
|
|
|
484
484
|
storage: {
|
|
485
485
|
bigquery: true,
|
|
486
|
-
firestore: {
|
|
487
|
-
enabled: true,
|
|
488
|
-
path: 'alerts/{date}/BehavioralAnomaly/{entityId}',
|
|
489
|
-
merge: true
|
|
486
|
+
firestore: {
|
|
487
|
+
enabled: true,
|
|
488
|
+
path: 'alerts/{date}/BehavioralAnomaly/{entityId}',
|
|
489
|
+
merge: true
|
|
490
490
|
}
|
|
491
491
|
},
|
|
492
|
-
|
|
492
|
+
|
|
493
493
|
userType: 'POPULAR_INVESTOR',
|
|
494
494
|
alert: {
|
|
495
495
|
id: 'behavioral_anomaly_v4',
|
|
496
496
|
frontendName: 'Behavioral Risk Intelligence',
|
|
497
|
-
|
|
498
|
-
|
|
497
|
+
description: 'Alert when a Popular Investor shows elevated behavioral risk',
|
|
498
|
+
messageTemplate: '{piUsername} shows behavioral risk: {primaryDriver} (score: {anomalyScore})',
|
|
499
|
+
severity: 'high',
|
|
500
|
+
configKey: 'behavioralAnomaly',
|
|
501
|
+
isDynamic: true,
|
|
502
|
+
dynamicConfig: {
|
|
503
|
+
thresholds: [
|
|
504
|
+
{
|
|
505
|
+
key: 'minScore',
|
|
506
|
+
type: 'number',
|
|
507
|
+
label: 'Minimum anomaly score',
|
|
508
|
+
description: 'Only alert if anomaly score is above this level',
|
|
509
|
+
default: 3.5,
|
|
510
|
+
min: 0,
|
|
511
|
+
max: 10,
|
|
512
|
+
step: 0.5,
|
|
513
|
+
unit: 'score'
|
|
514
|
+
}
|
|
515
|
+
],
|
|
516
|
+
resultFields: {
|
|
517
|
+
primaryDriver: 'driver',
|
|
518
|
+
anomalyScore: 'score',
|
|
519
|
+
driverSignificance: 'driverValue'
|
|
520
|
+
}
|
|
521
|
+
}
|
|
499
522
|
}
|
|
500
523
|
};
|
|
501
524
|
}
|
|
@@ -547,15 +570,15 @@ class BehavioralAnomaly extends Computation {
|
|
|
547
570
|
// 3. Historical Extraction (30 days)
|
|
548
571
|
const historicalFeatures = [];
|
|
549
572
|
const lookbackDate = new Date(date);
|
|
550
|
-
lookbackDate.setDate(lookbackDate.getDate() - 30);
|
|
573
|
+
lookbackDate.setDate(lookbackDate.getDate() - 30);
|
|
551
574
|
|
|
552
575
|
let prevDay = null;
|
|
553
576
|
let prevPrevDay = null;
|
|
554
|
-
|
|
577
|
+
|
|
555
578
|
for (let d = new Date(lookbackDate); d < new Date(date); d.setDate(d.getDate() + 1)) {
|
|
556
579
|
const dStr = d.toISOString().slice(0, 10);
|
|
557
580
|
const dayData = dailyData.get(dStr);
|
|
558
|
-
|
|
581
|
+
|
|
559
582
|
if (dayData && dayData.portfolio) {
|
|
560
583
|
const features = FeatureExtractor.extract(dayData, prevDay, prevPrevDay, currentHistoryBlob, dayData.rankings, rules, maps);
|
|
561
584
|
historicalFeatures.push(features);
|
|
@@ -565,8 +588,8 @@ class BehavioralAnomaly extends Computation {
|
|
|
565
588
|
}
|
|
566
589
|
|
|
567
590
|
if (historicalFeatures.length < 15) {
|
|
568
|
-
|
|
569
|
-
|
|
591
|
+
this.setResult(entityId, { triggered: false, status: 'INSUFFICIENT_HISTORY' });
|
|
592
|
+
return;
|
|
570
593
|
}
|
|
571
594
|
|
|
572
595
|
// 4. Today's Features
|
|
@@ -576,19 +599,19 @@ class BehavioralAnomaly extends Computation {
|
|
|
576
599
|
// 5. Detection & Scoring
|
|
577
600
|
const recentVectors = historicalFeatures.slice(-5).map(f => f.vector);
|
|
578
601
|
const regime = AdvancedMath.detectRegime(recentVectors);
|
|
579
|
-
historicalFeatures.forEach(f => f.metadata.regime = regime);
|
|
580
|
-
|
|
602
|
+
historicalFeatures.forEach(f => f.metadata.regime = regime);
|
|
603
|
+
|
|
581
604
|
const scores = AnomalyEngine.score(todayFeatures, historicalFeatures, regime);
|
|
582
605
|
const prediction = PredictiveEngine.forecastRisk(todayFeatures, historicalFeatures, scores.overall);
|
|
583
606
|
|
|
584
607
|
const featureNames = [
|
|
585
|
-
'Sector HHI', 'Martingale', 'Avg Leverage', 'Risk Score', 'Complexity', 'Exposure',
|
|
586
|
-
'Entropy', 'Drawdown', 'Win Rate Dev', 'Skewness', 'Stress Ratio', 'Credit Imbal',
|
|
608
|
+
'Sector HHI', 'Martingale', 'Avg Leverage', 'Risk Score', 'Complexity', 'Exposure',
|
|
609
|
+
'Entropy', 'Drawdown', 'Win Rate Dev', 'Skewness', 'Stress Ratio', 'Credit Imbal',
|
|
587
610
|
'High Lev Freq', 'Copier Mom', 'AUM Tier', 'Exp Velocity', 'Risk Accel', 'Behav Mom'
|
|
588
611
|
];
|
|
589
612
|
|
|
590
613
|
const interpretation = SemanticInterpreter.interpret(scores, todayFeatures, prediction, regime, featureNames);
|
|
591
|
-
|
|
614
|
+
|
|
592
615
|
const THRESHOLD = 3.5;
|
|
593
616
|
const triggered = scores.overall > THRESHOLD || prediction.probability7d > 0.75;
|
|
594
617
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const { Computation } = require('../framework');
|
|
8
8
|
|
|
9
9
|
class NewSectorExposure extends Computation {
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
constructor() {
|
|
12
12
|
super();
|
|
13
13
|
}
|
|
@@ -18,7 +18,7 @@ class NewSectorExposure extends Computation {
|
|
|
18
18
|
type: 'per-entity',
|
|
19
19
|
category: 'alerts',
|
|
20
20
|
isHistorical: true,
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
requires: {
|
|
23
23
|
'portfolio_snapshots': {
|
|
24
24
|
lookback: 14, // INCREASED: Look back 2 weeks to find last known state
|
|
@@ -116,7 +116,7 @@ class NewSectorExposure extends Computation {
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
const todayRow = history.find(d => toDateStr(d.date) === date);
|
|
119
|
-
|
|
119
|
+
|
|
120
120
|
// Find the first row where date is strictly less than target date
|
|
121
121
|
// Since we sorted descending, this is the "Last Known" date
|
|
122
122
|
const previousRow = history.find(d => toDateStr(d.date) < date);
|
|
@@ -131,11 +131,11 @@ class NewSectorExposure extends Computation {
|
|
|
131
131
|
const getSectorMap = (pData) => {
|
|
132
132
|
const map = new Map();
|
|
133
133
|
const positions = rules.portfolio.extractPositions(pData);
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
positions.forEach(pos => {
|
|
136
136
|
const instrumentId = rules.portfolio.getInstrumentId(pos);
|
|
137
|
-
const invested = rules.portfolio.getInvested(pos);
|
|
138
|
-
|
|
137
|
+
const invested = rules.portfolio.getInvested(pos);
|
|
138
|
+
|
|
139
139
|
if (instrumentId && invested > 0) {
|
|
140
140
|
const sectorName = resolveSector(instrumentId);
|
|
141
141
|
if (sectorName) {
|
|
@@ -167,7 +167,7 @@ class NewSectorExposure extends Computation {
|
|
|
167
167
|
currentSectors: Array.from(todaySectors.keys()),
|
|
168
168
|
previousSectors: isBaselineReset ? [] : Array.from(previousSectors.keys()),
|
|
169
169
|
newExposures,
|
|
170
|
-
sectorName: newExposures.join(', '),
|
|
170
|
+
sectorName: newExposures.join(', '),
|
|
171
171
|
isBaselineReset,
|
|
172
172
|
triggered: newExposures.length > 0,
|
|
173
173
|
_metadata: {
|
|
@@ -13,7 +13,7 @@ class NewSocialPost extends Computation {
|
|
|
13
13
|
type: 'per-entity',
|
|
14
14
|
category: 'alerts',
|
|
15
15
|
isHistorical: true,
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
requires: {
|
|
18
18
|
'social_post_snapshots': {
|
|
19
19
|
lookback: 5, // Short lookback just to ensure we catch the specific row
|
|
@@ -49,7 +49,7 @@ class NewSocialPost extends Computation {
|
|
|
49
49
|
|
|
50
50
|
// 1. Get History
|
|
51
51
|
const history = data['social_post_snapshots'] || [];
|
|
52
|
-
|
|
52
|
+
|
|
53
53
|
// Helper: Handle BigQuery Date Objects
|
|
54
54
|
const toDateStr = (d) => {
|
|
55
55
|
if (!d) return null;
|
|
@@ -63,10 +63,10 @@ class NewSocialPost extends Computation {
|
|
|
63
63
|
|
|
64
64
|
if (!todayRow) {
|
|
65
65
|
// No snapshot exists for this date yet, so we can't determine if they posted.
|
|
66
|
-
this.setResult(entityId, {
|
|
67
|
-
hasNewPost: false,
|
|
66
|
+
this.setResult(entityId, {
|
|
67
|
+
hasNewPost: false,
|
|
68
68
|
triggered: false,
|
|
69
|
-
reason: "No snapshot found for date"
|
|
69
|
+
reason: "No snapshot found for date"
|
|
70
70
|
});
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
@@ -88,7 +88,7 @@ class NewSocialPost extends Computation {
|
|
|
88
88
|
|
|
89
89
|
if (postDateStr === date) {
|
|
90
90
|
hasNewPost = true;
|
|
91
|
-
|
|
91
|
+
|
|
92
92
|
// Track the latest one for the alert title
|
|
93
93
|
if (!latestPost || postDateObj > rules.social.getPostDate(latestPost)) {
|
|
94
94
|
latestPost = post;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { Computation } = require('../framework');
|
|
2
2
|
|
|
3
3
|
class PositionInvestedIncrease extends Computation {
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
constructor() {
|
|
6
6
|
super();
|
|
7
7
|
this.THRESHOLD_PP = 5.0; // Percentage Points
|
|
@@ -13,7 +13,7 @@ class PositionInvestedIncrease extends Computation {
|
|
|
13
13
|
type: 'per-entity',
|
|
14
14
|
category: 'alerts',
|
|
15
15
|
isHistorical: true,
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
requires: {
|
|
18
18
|
'portfolio_snapshots': {
|
|
19
19
|
lookback: 1, // Today + Yesterday
|
|
@@ -53,9 +53,9 @@ class PositionInvestedIncrease extends Computation {
|
|
|
53
53
|
}
|
|
54
54
|
],
|
|
55
55
|
resultFields: {
|
|
56
|
-
matchValue: 'diff',
|
|
57
|
-
currentValue: 'moveCount',
|
|
58
|
-
metadata: ['symbol', 'prev', 'curr']
|
|
56
|
+
matchValue: 'diff',
|
|
57
|
+
currentValue: 'moveCount',
|
|
58
|
+
metadata: ['symbol', 'prev', 'curr']
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
}
|
|
@@ -72,7 +72,7 @@ class PositionInvestedIncrease extends Computation {
|
|
|
72
72
|
// 2. Get Data
|
|
73
73
|
const history = data['portfolio_snapshots'] || [];
|
|
74
74
|
const todayRow = history.find(d => d.date === date);
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
const yDate = new Date(date);
|
|
77
77
|
yDate.setDate(yDate.getDate() - 1);
|
|
78
78
|
const yDateStr = yDate.toISOString().split('T')[0];
|
|
@@ -82,8 +82,8 @@ class PositionInvestedIncrease extends Computation {
|
|
|
82
82
|
|
|
83
83
|
// 3. Extract Positions
|
|
84
84
|
const currentPositions = rules.portfolio.extractPositions(rules.portfolio.extractPortfolioData(todayRow));
|
|
85
|
-
const previousPositions = yesterdayRow
|
|
86
|
-
? rules.portfolio.extractPositions(rules.portfolio.extractPortfolioData(yesterdayRow))
|
|
85
|
+
const previousPositions = yesterdayRow
|
|
86
|
+
? rules.portfolio.extractPositions(rules.portfolio.extractPortfolioData(yesterdayRow))
|
|
87
87
|
: [];
|
|
88
88
|
|
|
89
89
|
// Map previous for O(1) lookup
|
|
@@ -97,12 +97,12 @@ class PositionInvestedIncrease extends Computation {
|
|
|
97
97
|
|
|
98
98
|
for (const currPos of currentPositions) {
|
|
99
99
|
const id = String(rules.portfolio.getInstrumentId(currPos));
|
|
100
|
-
|
|
100
|
+
|
|
101
101
|
// Resolve Ticker using Rules (Instruments) or fallback
|
|
102
102
|
let ticker = rules.instruments.getTickerById(tickerMap, id) || currPos.Symbol || `ID:${id}`;
|
|
103
103
|
|
|
104
104
|
const currInvested = Number(rules.portfolio.getInvested(currPos)) || 0;
|
|
105
|
-
|
|
105
|
+
|
|
106
106
|
// Get previous
|
|
107
107
|
const prevPos = prevMap.get(id);
|
|
108
108
|
const prevInvested = prevPos ? (Number(rules.portfolio.getInvested(prevPos)) || 0) : 0;
|
|
@@ -132,7 +132,7 @@ class PositionInvestedIncrease extends Computation {
|
|
|
132
132
|
moves: significantMoves,
|
|
133
133
|
isBaselineReset: (!yesterdayRow),
|
|
134
134
|
triggered: significantMoves.length > 0,
|
|
135
|
-
|
|
135
|
+
|
|
136
136
|
// Hoisted fields
|
|
137
137
|
symbol: topMove.symbol || null,
|
|
138
138
|
diff: topMove.diff || null,
|
|
@@ -162,6 +162,14 @@ module.exports = {
|
|
|
162
162
|
description: 'Daily alert trigger history'
|
|
163
163
|
},
|
|
164
164
|
|
|
165
|
+
// User Alert Settings (watchlist and alert config snapshot per day)
|
|
166
|
+
'user_alert_settings': {
|
|
167
|
+
dateField: 'date',
|
|
168
|
+
entityField: 'user_id',
|
|
169
|
+
clusterFields: ['user_id', 'watchlist_id'],
|
|
170
|
+
description: 'Daily user watchlist and alert configuration'
|
|
171
|
+
},
|
|
172
|
+
|
|
165
173
|
// Instrument Insights
|
|
166
174
|
'instrument_insights': {
|
|
167
175
|
dateField: 'date',
|
|
@@ -126,6 +126,7 @@ class ManifestBuilder {
|
|
|
126
126
|
|
|
127
127
|
outputTable: config.outputTable || null,
|
|
128
128
|
// --------------------------------------
|
|
129
|
+
alert: config.alert || null,
|
|
129
130
|
|
|
130
131
|
requires: config.requires || {},
|
|
131
132
|
dependencies: (config.dependencies || []).map(d => this._normalize(d)),
|
|
@@ -595,6 +595,15 @@ const SCHEMAS = {
|
|
|
595
595
|
{ name: 'metadata', type: 'JSON', mode: 'NULLABLE' },
|
|
596
596
|
{ name: 'last_triggered', type: 'TIMESTAMP', mode: 'NULLABLE' },
|
|
597
597
|
{ name: 'last_updated', type: 'TIMESTAMP', mode: 'REQUIRED' }
|
|
598
|
+
],
|
|
599
|
+
user_alert_settings: [
|
|
600
|
+
{ name: 'date', type: 'DATE', mode: 'REQUIRED' },
|
|
601
|
+
{ name: 'user_id', type: 'STRING', mode: 'REQUIRED' },
|
|
602
|
+
{ name: 'watchlist_id', type: 'STRING', mode: 'REQUIRED' },
|
|
603
|
+
{ name: 'watchlist_name', type: 'STRING', mode: 'NULLABLE' },
|
|
604
|
+
{ name: 'visibility', type: 'STRING', mode: 'NULLABLE' },
|
|
605
|
+
{ name: 'items', type: 'JSON', mode: 'NULLABLE' },
|
|
606
|
+
{ name: 'updated_at', type: 'TIMESTAMP', mode: 'REQUIRED' }
|
|
598
607
|
]
|
|
599
608
|
};
|
|
600
609
|
|
|
@@ -891,6 +900,28 @@ async function ensurePIAlertHistoryTable(logger = null) {
|
|
|
891
900
|
);
|
|
892
901
|
}
|
|
893
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Ensure user_alert_settings table exists
|
|
905
|
+
* @param {object} logger - Logger instance
|
|
906
|
+
* @returns {Promise<Table>}
|
|
907
|
+
*/
|
|
908
|
+
async function ensureUserAlertSettingsTable(logger = null) {
|
|
909
|
+
const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
|
|
910
|
+
const tableId = 'user_alert_settings';
|
|
911
|
+
const schema = getSchema(tableId);
|
|
912
|
+
|
|
913
|
+
return await ensureTableExists(
|
|
914
|
+
datasetId,
|
|
915
|
+
tableId,
|
|
916
|
+
schema,
|
|
917
|
+
{
|
|
918
|
+
partitionField: 'date',
|
|
919
|
+
clusterFields: ['user_id', 'watchlist_id']
|
|
920
|
+
},
|
|
921
|
+
logger
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
894
925
|
/**
|
|
895
926
|
* Query portfolio data from BigQuery
|
|
896
927
|
* @param {string} dateStr - Date string (YYYY-MM-DD)
|
|
@@ -2272,6 +2303,7 @@ module.exports = {
|
|
|
2272
2303
|
ensurePIPageViewsTable,
|
|
2273
2304
|
ensureWatchlistMembershipTable,
|
|
2274
2305
|
ensurePIAlertHistoryTable,
|
|
2306
|
+
ensureUserAlertSettingsTable,
|
|
2275
2307
|
queryPortfolioData,
|
|
2276
2308
|
queryHistoryData,
|
|
2277
2309
|
querySocialData,
|