bulltrackers-module 1.0.777 → 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.
Files changed (24) hide show
  1. package/functions/alert-system/helpers/alert_helpers.js +114 -90
  2. package/functions/alert-system/helpers/alert_manifest_loader.js +88 -99
  3. package/functions/alert-system/index.js +81 -138
  4. package/functions/alert-system/tests/stage1-alert-manifest.test.js +94 -0
  5. package/functions/alert-system/tests/stage2-alert-metadata.test.js +93 -0
  6. package/functions/alert-system/tests/stage3-alert-handler.test.js +79 -0
  7. package/functions/api-v2/helpers/data-fetchers/firestore.js +613 -478
  8. package/functions/api-v2/routes/popular_investors.js +7 -7
  9. package/functions/api-v2/routes/profile.js +2 -1
  10. package/functions/api-v2/tests/stage4-profile-paths.test.js +52 -0
  11. package/functions/api-v2/tests/stage5-aum-bigquery.test.js +81 -0
  12. package/functions/api-v2/tests/stage7-pi-page-views.test.js +55 -0
  13. package/functions/api-v2/tests/stage8-watchlist-membership.test.js +49 -0
  14. package/functions/api-v2/tests/stage9-user-alert-settings.test.js +81 -0
  15. package/functions/computation-system-v2/computations/BehavioralAnomaly.js +104 -81
  16. package/functions/computation-system-v2/computations/NewSectorExposure.js +7 -7
  17. package/functions/computation-system-v2/computations/NewSocialPost.js +6 -6
  18. package/functions/computation-system-v2/computations/PositionInvestedIncrease.js +11 -11
  19. package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +1 -1
  20. package/functions/computation-system-v2/config/bulltrackers.config.js +8 -0
  21. package/functions/computation-system-v2/framework/core/Manifest.js +1 -0
  22. package/functions/computation-system-v2/handlers/scheduler.js +15 -24
  23. package/functions/core/utils/bigquery_utils.js +32 -0
  24. 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
- severity: 'high',
498
- isDynamic: true
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
- this.setResult(entityId, { triggered: false, status: 'INSUFFICIENT_HISTORY' });
569
- return;
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,
@@ -32,7 +32,7 @@ class SignedInUserPIProfileMetrics extends Computation {
32
32
  bigquery: true,
33
33
  firestore: {
34
34
  enabled: true,
35
- path: 'users/{entityId}/pi_profile_metrics/{date}',
35
+ path: 'users/{entityId}/signed_in_user_pi_profile_metrics/{date}',
36
36
  merge: true
37
37
  }
38
38
  }
@@ -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)),