bulltrackers-module 1.0.733 → 1.0.734

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 (56) hide show
  1. package/functions/computation-system-v2/README.md +152 -0
  2. package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +720 -0
  3. package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +176 -0
  4. package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +294 -0
  5. package/functions/computation-system-v2/computations/TestComputation.js +46 -0
  6. package/functions/computation-system-v2/computations/UserPortfolioSummary.js +172 -0
  7. package/functions/computation-system-v2/config/bulltrackers.config.js +317 -0
  8. package/functions/computation-system-v2/framework/core/Computation.js +73 -0
  9. package/functions/computation-system-v2/framework/core/Manifest.js +223 -0
  10. package/functions/computation-system-v2/framework/core/RuleInjector.js +53 -0
  11. package/functions/computation-system-v2/framework/core/Rules.js +231 -0
  12. package/functions/computation-system-v2/framework/core/RunAnalyzer.js +163 -0
  13. package/functions/computation-system-v2/framework/cost/CostTracker.js +154 -0
  14. package/functions/computation-system-v2/framework/data/DataFetcher.js +399 -0
  15. package/functions/computation-system-v2/framework/data/QueryBuilder.js +232 -0
  16. package/functions/computation-system-v2/framework/data/SchemaRegistry.js +287 -0
  17. package/functions/computation-system-v2/framework/execution/Orchestrator.js +498 -0
  18. package/functions/computation-system-v2/framework/execution/TaskRunner.js +35 -0
  19. package/functions/computation-system-v2/framework/execution/middleware/CostTrackerMiddleware.js +32 -0
  20. package/functions/computation-system-v2/framework/execution/middleware/LineageMiddleware.js +32 -0
  21. package/functions/computation-system-v2/framework/execution/middleware/Middleware.js +14 -0
  22. package/functions/computation-system-v2/framework/execution/middleware/ProfilerMiddleware.js +47 -0
  23. package/functions/computation-system-v2/framework/index.js +45 -0
  24. package/functions/computation-system-v2/framework/lineage/LineageTracker.js +147 -0
  25. package/functions/computation-system-v2/framework/monitoring/Profiler.js +80 -0
  26. package/functions/computation-system-v2/framework/resilience/Checkpointer.js +66 -0
  27. package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +327 -0
  28. package/functions/computation-system-v2/framework/storage/StateRepository.js +286 -0
  29. package/functions/computation-system-v2/framework/storage/StorageManager.js +469 -0
  30. package/functions/computation-system-v2/framework/storage/index.js +9 -0
  31. package/functions/computation-system-v2/framework/testing/ComputationTester.js +86 -0
  32. package/functions/computation-system-v2/framework/utils/Graph.js +205 -0
  33. package/functions/computation-system-v2/handlers/dispatcher.js +109 -0
  34. package/functions/computation-system-v2/handlers/index.js +23 -0
  35. package/functions/computation-system-v2/handlers/onDemand.js +289 -0
  36. package/functions/computation-system-v2/handlers/scheduler.js +327 -0
  37. package/functions/computation-system-v2/index.js +163 -0
  38. package/functions/computation-system-v2/rules/index.js +49 -0
  39. package/functions/computation-system-v2/rules/instruments.js +465 -0
  40. package/functions/computation-system-v2/rules/metrics.js +304 -0
  41. package/functions/computation-system-v2/rules/portfolio.js +534 -0
  42. package/functions/computation-system-v2/rules/rankings.js +655 -0
  43. package/functions/computation-system-v2/rules/social.js +562 -0
  44. package/functions/computation-system-v2/rules/trades.js +545 -0
  45. package/functions/computation-system-v2/scripts/migrate-sectors.js +73 -0
  46. package/functions/computation-system-v2/test/test-dispatcher.js +317 -0
  47. package/functions/computation-system-v2/test/test-framework.js +500 -0
  48. package/functions/computation-system-v2/test/test-real-execution.js +166 -0
  49. package/functions/computation-system-v2/test/test-real-integration.js +194 -0
  50. package/functions/computation-system-v2/test/test-refactor-e2e.js +131 -0
  51. package/functions/computation-system-v2/test/test-results.json +31 -0
  52. package/functions/computation-system-v2/test/test-risk-metrics-computation.js +329 -0
  53. package/functions/computation-system-v2/test/test-scheduler.js +204 -0
  54. package/functions/computation-system-v2/test/test-storage.js +449 -0
  55. package/functions/orchestrator/index.js +18 -26
  56. package/package.json +3 -2
@@ -0,0 +1,720 @@
1
+ /**
2
+ * @fileoverview Popular Investor Profile Metrics - v2 Adaptation
3
+ *
4
+ * This is the most complex computation, migrated to demonstrate the v2 framework.
5
+ *
6
+ * Original v1 features preserved:
7
+ * - Profile page metrics for Popular Investors
8
+ * - Social engagement (7-day accumulation)
9
+ * - Profitable positions from trade history
10
+ * - Sector/asset exposure analysis
11
+ * - Rankings, ratings, page views, watchlist data
12
+ * - Alert history with 7-day metrics
13
+ * - Exposure over time (historical continuity)
14
+ *
15
+ * v2 Changes:
16
+ * - Uses `getConfig()` instead of `getMetadata()` + `getDependencies()`
17
+ * - Directly references BigQuery table names in `requires`
18
+ * - Data accessed via `context.data['table_name']`
19
+ * - Simplified series handling through lookback configuration
20
+ */
21
+
22
+ const { Computation } = require('../framework');
23
+
24
+ class PopularInvestorProfileMetrics extends Computation {
25
+
26
+ /**
27
+ * v2 Configuration - Single source of truth for computation requirements.
28
+ */
29
+ static getConfig() {
30
+ return {
31
+ name: 'PopularInvestorProfileMetrics',
32
+ category: 'popular_investor',
33
+
34
+ // Direct table references with lookback configuration
35
+ requires: {
36
+ // Core data - mandatory for execution
37
+ 'portfolio_snapshots': {
38
+ lookback: 0,
39
+ mandatory: true,
40
+ filter: { user_type: 'POPULAR_INVESTOR' }
41
+ },
42
+ 'trade_history_snapshots': {
43
+ lookback: 0,
44
+ mandatory: true,
45
+ filter: { user_type: 'POPULAR_INVESTOR' }
46
+ },
47
+
48
+ // Rankings - for AUM, risk score, gain, copiers
49
+ 'pi_rankings': {
50
+ lookback: 7, // Self-healing: use latest available
51
+ mandatory: false
52
+ },
53
+
54
+ // Ratings - 30-day average
55
+ 'pi_ratings': {
56
+ lookback: 30,
57
+ mandatory: false
58
+ },
59
+
60
+ // Page views - 7-day accumulation
61
+ 'pi_page_views': {
62
+ lookback: 7,
63
+ mandatory: false
64
+ },
65
+
66
+ // Watchlist - 30-day trend
67
+ 'watchlist_membership': {
68
+ lookback: 30,
69
+ mandatory: false
70
+ },
71
+
72
+ // Alert history - 7-day metrics
73
+ 'pi_alert_history': {
74
+ lookback: 7,
75
+ mandatory: false
76
+ },
77
+
78
+ // Social posts - 7-day engagement
79
+ 'social_post_snapshots': {
80
+ lookback: 7,
81
+ mandatory: false,
82
+ filter: { user_type: 'POPULAR_INVESTOR' }
83
+ },
84
+
85
+ // Ticker mappings for display names
86
+ 'ticker_mappings': {
87
+ lookback: 0, // Not date-partitioned
88
+ mandatory: false
89
+ }
90
+ },
91
+
92
+ // No computation dependencies
93
+ dependencies: [],
94
+
95
+ // Runs per user
96
+ type: 'per-entity',
97
+
98
+ // Needs previous day's result for exposure over time
99
+ isHistorical: true,
100
+
101
+ // Output config
102
+ ttlDays: 60
103
+ };
104
+ }
105
+
106
+ static getSchema() {
107
+ return {
108
+ type: 'object',
109
+ properties: {
110
+ username: { type: 'string' },
111
+ socialEngagement: { type: 'object' },
112
+ profitablePositions: { type: 'object' },
113
+ topWinningPositions: { type: 'object' },
114
+ sectorPerformance: { type: 'object' },
115
+ sectorExposure: { type: 'object' },
116
+ assetExposure: { type: 'object' },
117
+ portfolioSummary: { type: 'object' },
118
+ rankingsData: { type: 'object' },
119
+ ratingsData: { type: 'object' },
120
+ pageViewsData: { type: 'object' },
121
+ watchlistData: { type: 'object' },
122
+ alertHistoryData: { type: 'object' },
123
+ sectorExposureOverTime: { type: 'object' },
124
+ assetExposureOverTime: { type: 'object' },
125
+ alertMetrics: { type: 'object' }
126
+ }
127
+ };
128
+ }
129
+
130
+ static getWeight() {
131
+ return 10.0; // Complex per-user computation
132
+ }
133
+
134
+ /**
135
+ * Process a single user's profile metrics.
136
+ */
137
+ async process(context) {
138
+ const { date, entityId: userId, data, previousResult } = context;
139
+
140
+ // Extract data from context
141
+ const portfolio = data['portfolio_snapshots'];
142
+ const historyDoc = data['trade_history_snapshots'];
143
+ const rankings = data['pi_rankings'];
144
+ const ratings = data['pi_ratings'];
145
+ const pageViews = data['pi_page_views'];
146
+ const watchlist = data['watchlist_membership'];
147
+ const alerts = data['pi_alert_history'];
148
+ const social = data['social_post_snapshots'];
149
+ const tickerMappings = data['ticker_mappings'];
150
+
151
+ // Build instrument mappings
152
+ const mappings = this._buildMappings(tickerMappings);
153
+
154
+ // Initialize result structure
155
+ const result = this._initializeResult();
156
+
157
+ // ==========================================================================================
158
+ // 1. Social Engagement (7-Day Accumulation + Chart)
159
+ // ==========================================================================================
160
+ this._processSocialEngagement(result, social, userId);
161
+
162
+ // ==========================================================================================
163
+ // 2. Profitable Positions (From Trade History)
164
+ // ==========================================================================================
165
+ this._processProfitablePositions(result, historyDoc);
166
+
167
+ // ==========================================================================================
168
+ // 3. Top Winning Positions
169
+ // ==========================================================================================
170
+ this._processTopWinningPositions(result, portfolio, historyDoc, mappings);
171
+
172
+ // ==========================================================================================
173
+ // 4. Sector Performance
174
+ // ==========================================================================================
175
+ this._processSectorPerformance(result, portfolio, mappings);
176
+
177
+ // ==========================================================================================
178
+ // 5. & 6. Exposure & Portfolio Summary
179
+ // ==========================================================================================
180
+ this._processExposureAndSummary(result, portfolio, mappings);
181
+
182
+ // ==========================================================================================
183
+ // 7. Rankings Data
184
+ // ==========================================================================================
185
+ this._processRankingsData(result, rankings, userId);
186
+
187
+ // ==========================================================================================
188
+ // 8. Username (from rankings)
189
+ // ==========================================================================================
190
+ if (rankings) {
191
+ const rankEntry = this._findLatestRankEntry(rankings, userId);
192
+ if (rankEntry) {
193
+ result.username = rankEntry.UserName || rankEntry.username || null;
194
+ }
195
+ }
196
+
197
+ // ==========================================================================================
198
+ // 9. Ratings Data (30-Day Average + Trend)
199
+ // ==========================================================================================
200
+ this._processRatingsData(result, ratings, userId);
201
+
202
+ // ==========================================================================================
203
+ // 10. Page Views Data (7-Day Sum + Daily Chart)
204
+ // ==========================================================================================
205
+ this._processPageViewsData(result, pageViews, userId);
206
+
207
+ // ==========================================================================================
208
+ // 11. Watchlist Data (Latest Snapshot + 30-Day Trend)
209
+ // ==========================================================================================
210
+ this._processWatchlistData(result, watchlist, userId);
211
+
212
+ // ==========================================================================================
213
+ // 12. Alert History (Accumulation + Metrics)
214
+ // ==========================================================================================
215
+ this._processAlertHistory(result, alerts, userId);
216
+
217
+ // ==========================================================================================
218
+ // 13. Exposure Over Time (Requires Previous State)
219
+ // ==========================================================================================
220
+ this._processExposureOverTime(result, previousResult, date);
221
+
222
+ // Store result
223
+ this.setResult(userId, result);
224
+ }
225
+
226
+ // =========================================================================
227
+ // PRIVATE METHODS
228
+ // =========================================================================
229
+
230
+ _initializeResult() {
231
+ return {
232
+ username: null,
233
+ socialEngagement: { chartType: 'line', data: [] },
234
+ profitablePositions: { chartType: 'bar', data: [] },
235
+ topWinningPositions: { chartType: 'table', data: [] },
236
+ sectorPerformance: { bestSector: null, worstSector: null, bestSectorProfit: 0, worstSectorProfit: 0 },
237
+ sectorExposure: { chartType: 'pie', data: {} },
238
+ assetExposure: { chartType: 'pie', data: {} },
239
+ portfolioSummary: { totalInvested: 0, totalProfit: 0, profitPercent: 0 },
240
+ rankingsData: { aum: 0, riskScore: 0, gain: 0, copiers: 0, winRatio: 0, trades: 0 },
241
+ ratingsData: {
242
+ averageRating: 0,
243
+ totalRatings: 0,
244
+ ratingsOverTime: { chartType: 'line', data: [] }
245
+ },
246
+ pageViewsData: {
247
+ totalViews: 0,
248
+ uniqueViewers: 0,
249
+ viewsOverTime: { chartType: 'bar', data: [] }
250
+ },
251
+ watchlistData: {
252
+ totalUsers: 0,
253
+ publicWatchlistCount: 0,
254
+ privateWatchlistCount: 0,
255
+ watchlistOverTime: { chartType: 'line', data: [] }
256
+ },
257
+ alertHistoryData: { triggeredAlerts: [], alertCountsOverTime: { chartType: 'bar', data: [] } },
258
+ sectorExposureOverTime: { chartType: 'line', data: [] },
259
+ assetExposureOverTime: { chartType: 'line', data: [] },
260
+ alertMetrics: { totalLast7Days: 0, breakdown: {} }
261
+ };
262
+ }
263
+
264
+ _buildMappings(tickerMappings) {
265
+ const mappings = {
266
+ instrumentToTicker: {},
267
+ instrumentToSector: {}
268
+ };
269
+
270
+ if (!tickerMappings) return mappings;
271
+
272
+ // Handle different data structures
273
+ if (Array.isArray(tickerMappings)) {
274
+ for (const row of tickerMappings) {
275
+ const id = row.instrument_id || row.instrumentId;
276
+ if (id) {
277
+ mappings.instrumentToTicker[String(id)] = row.ticker || row.symbol || `ID${id}`;
278
+ if (row.sector) mappings.instrumentToSector[String(id)] = row.sector;
279
+ }
280
+ }
281
+ } else if (typeof tickerMappings === 'object') {
282
+ for (const [id, data] of Object.entries(tickerMappings)) {
283
+ if (typeof data === 'string') {
284
+ mappings.instrumentToTicker[id] = data;
285
+ } else if (typeof data === 'object') {
286
+ mappings.instrumentToTicker[id] = data.ticker || data.symbol || `ID${id}`;
287
+ if (data.sector) mappings.instrumentToSector[id] = data.sector;
288
+ }
289
+ }
290
+ }
291
+
292
+ return mappings;
293
+ }
294
+
295
+ _processSocialEngagement(result, social, userId) {
296
+ if (!social) return;
297
+
298
+ // social is date-keyed: { "2026-01-24": [...], "2026-01-23": [...] }
299
+ const dates = Object.keys(social).sort();
300
+
301
+ for (const dateStr of dates) {
302
+ const dailyData = social[dateStr];
303
+ if (!dailyData) continue;
304
+
305
+ // Find user's posts
306
+ let userPosts = [];
307
+ if (Array.isArray(dailyData)) {
308
+ userPosts = dailyData.filter(p => String(p.user_id || p.userId) === String(userId));
309
+ } else if (dailyData[userId]) {
310
+ userPosts = Array.isArray(dailyData[userId]) ? dailyData[userId] : [dailyData[userId]];
311
+ }
312
+
313
+ if (userPosts.length > 0) {
314
+ let likes = 0, comments = 0;
315
+ for (const post of userPosts) {
316
+ likes += (post.LikesCount || post.likes || 0);
317
+ comments += (post.CommentsCount || post.comments || 0);
318
+ }
319
+ result.socialEngagement.data.push({ date: dateStr, likes, comments });
320
+ }
321
+ }
322
+ }
323
+
324
+ _processProfitablePositions(result, historyDoc) {
325
+ if (!historyDoc) return;
326
+
327
+ const trades = this._extractTrades(historyDoc);
328
+ const profitableMap = new Map();
329
+
330
+ for (const trade of trades) {
331
+ const closeDateTime = trade.CloseDateTime || trade.closeDateTime || trade.close_date;
332
+ if (!closeDateTime) continue;
333
+
334
+ const closeDate = new Date(closeDateTime);
335
+ if (isNaN(closeDate.getTime())) continue;
336
+
337
+ const dateKey = closeDate.toISOString().split('T')[0];
338
+ const existing = profitableMap.get(dateKey) || { date: dateKey, profitableCount: 0, totalCount: 0 };
339
+ existing.totalCount++;
340
+
341
+ const netProfit = trade.NetProfit || trade.netProfit || trade.net_profit || 0;
342
+ if (netProfit > 0) existing.profitableCount++;
343
+
344
+ profitableMap.set(dateKey, existing);
345
+ }
346
+
347
+ result.profitablePositions.data = Array.from(profitableMap.values())
348
+ .sort((a, b) => a.date.localeCompare(b.date))
349
+ .slice(-30);
350
+ }
351
+
352
+ _processTopWinningPositions(result, portfolio, historyDoc, mappings) {
353
+ const topWinning = [];
354
+
355
+ // From current portfolio
356
+ if (portfolio) {
357
+ const positions = this._extractPositions(portfolio);
358
+ for (const pos of positions) {
359
+ const instrumentId = pos.InstrumentID || pos.instrumentId || pos.instrument_id;
360
+ const netProfit = pos.NetProfit || pos.netProfit || pos.net_profit || 0;
361
+ const invested = pos.Amount || pos.amount || pos.invested || 0;
362
+ const value = pos.Value || pos.value || invested + netProfit;
363
+
364
+ if (netProfit > 0 && instrumentId) {
365
+ const ticker = mappings.instrumentToTicker[String(instrumentId)] || `ID${instrumentId}`;
366
+ topWinning.push({ instrumentId, ticker, netProfit, invested, value, isCurrent: true, closeDate: null });
367
+ }
368
+ }
369
+ }
370
+
371
+ // From trade history
372
+ if (historyDoc) {
373
+ const trades = this._extractTrades(historyDoc);
374
+ for (const trade of trades) {
375
+ const netProfit = trade.NetProfit || trade.netProfit || trade.net_profit || 0;
376
+ const instrumentId = trade.InstrumentID || trade.instrumentId || trade.instrument_id;
377
+
378
+ if (netProfit > 0 && instrumentId) {
379
+ const ticker = mappings.instrumentToTicker[String(instrumentId)] || `ID${instrumentId}`;
380
+ const closeDate = trade.CloseDateTime || trade.closeDateTime || trade.close_date || null;
381
+ topWinning.push({ instrumentId, ticker, netProfit, invested: 0, value: 0, isCurrent: false, closeDate });
382
+ }
383
+ }
384
+ }
385
+
386
+ result.topWinningPositions.data = topWinning
387
+ .sort((a, b) => b.netProfit - a.netProfit)
388
+ .slice(0, 20);
389
+ }
390
+
391
+ _processSectorPerformance(result, portfolio, mappings) {
392
+ if (!portfolio || !mappings.instrumentToSector || Object.keys(mappings.instrumentToSector).length === 0) {
393
+ return;
394
+ }
395
+
396
+ const positions = this._extractPositions(portfolio);
397
+ const sectorProfits = new Map();
398
+
399
+ for (const pos of positions) {
400
+ const instrumentId = pos.InstrumentID || pos.instrumentId || pos.instrument_id;
401
+ const netProfit = pos.NetProfit || pos.netProfit || pos.net_profit || 0;
402
+ const invested = pos.Amount || pos.amount || pos.invested || 0;
403
+
404
+ if (instrumentId && invested > 0) {
405
+ const sector = mappings.instrumentToSector[String(instrumentId)] || 'Unknown';
406
+ const weightedProfit = invested * (netProfit / 100);
407
+ const existing = sectorProfits.get(sector) || { totalProfit: 0, totalInvested: 0 };
408
+ existing.totalProfit += weightedProfit;
409
+ existing.totalInvested += invested;
410
+ sectorProfits.set(sector, existing);
411
+ }
412
+ }
413
+
414
+ let bestSector = null, worstSector = null;
415
+ let bestProfit = -Infinity, worstProfit = Infinity;
416
+
417
+ for (const [sector, data] of sectorProfits.entries()) {
418
+ const profitPercent = data.totalInvested > 0 ? (data.totalProfit / data.totalInvested) * 100 : 0;
419
+ if (profitPercent > bestProfit) { bestProfit = profitPercent; bestSector = sector; }
420
+ if (profitPercent < worstProfit) { worstProfit = profitPercent; worstSector = sector; }
421
+ }
422
+
423
+ result.sectorPerformance = {
424
+ bestSector,
425
+ worstSector,
426
+ bestSectorProfit: bestProfit !== -Infinity ? bestProfit : 0,
427
+ worstSectorProfit: worstProfit !== Infinity ? worstProfit : 0
428
+ };
429
+ }
430
+
431
+ _processExposureAndSummary(result, portfolio, mappings) {
432
+ if (!portfolio) return;
433
+
434
+ const positions = this._extractPositions(portfolio);
435
+ const sectorMap = {};
436
+ const assetMap = {};
437
+ let total = 0, totalInvested = 0, totalProfit = 0;
438
+
439
+ for (const pos of positions) {
440
+ const instrumentId = pos.InstrumentID || pos.instrumentId || pos.instrument_id;
441
+ const invested = pos.Amount || pos.amount || pos.invested || 0;
442
+ const netProfit = pos.NetProfit || pos.netProfit || pos.net_profit || 0;
443
+ const value = pos.Value || pos.value || invested + netProfit;
444
+
445
+ total += value;
446
+ totalInvested += invested;
447
+ totalProfit += invested * (netProfit / 100);
448
+
449
+ const sector = mappings.instrumentToSector[String(instrumentId)] || 'Unknown';
450
+ sectorMap[sector] = (sectorMap[sector] || 0) + value;
451
+
452
+ const ticker = mappings.instrumentToTicker[String(instrumentId)] || `ID${instrumentId}`;
453
+ assetMap[ticker] = (assetMap[ticker] || 0) + value;
454
+ }
455
+
456
+ if (total > 0) {
457
+ Object.keys(sectorMap).forEach(k => {
458
+ result.sectorExposure.data[k] = Number(((sectorMap[k] / total) * 100).toFixed(2));
459
+ });
460
+
461
+ Object.entries(assetMap)
462
+ .sort(([, a], [, b]) => b - a)
463
+ .slice(0, 10)
464
+ .forEach(([k, v]) => {
465
+ result.assetExposure.data[k] = Number(((v / total) * 100).toFixed(2));
466
+ });
467
+ }
468
+
469
+ result.portfolioSummary = {
470
+ totalInvested: Number(totalInvested.toFixed(2)),
471
+ totalProfit: Number(totalProfit.toFixed(2)),
472
+ profitPercent: totalInvested > 0 ? Number(((totalProfit / totalInvested) * 100).toFixed(2)) : 0
473
+ };
474
+ }
475
+
476
+ _processRankingsData(result, rankings, userId) {
477
+ const rankEntry = this._findLatestRankEntry(rankings, userId);
478
+ if (!rankEntry) return;
479
+
480
+ result.rankingsData = {
481
+ aum: rankEntry.AUMTierDesc || rankEntry.Aum || rankEntry.aum || 0,
482
+ riskScore: rankEntry.RiskScore || rankEntry.riskScore || 0,
483
+ gain: rankEntry.Gain || rankEntry.gain || 0,
484
+ copiers: rankEntry.Copiers || rankEntry.copiers || 0,
485
+ winRatio: rankEntry.WinRatio || rankEntry.winRatio || 0,
486
+ trades: rankEntry.Trades || rankEntry.trades || 0
487
+ };
488
+ }
489
+
490
+ _findLatestRankEntry(rankings, userId) {
491
+ if (!rankings) return null;
492
+
493
+ // rankings is date-keyed: { "2026-01-24": [...], ... }
494
+ if (typeof rankings === 'object' && !Array.isArray(rankings)) {
495
+ const dates = Object.keys(rankings).sort().reverse();
496
+ for (const dateStr of dates) {
497
+ const dayRankings = rankings[dateStr];
498
+ if (Array.isArray(dayRankings)) {
499
+ const entry = dayRankings.find(r =>
500
+ String(r.CustomerId || r.pi_id || r.userId) === String(userId)
501
+ );
502
+ if (entry) return entry;
503
+ }
504
+ }
505
+ }
506
+
507
+ // Direct array
508
+ if (Array.isArray(rankings)) {
509
+ return rankings.find(r =>
510
+ String(r.CustomerId || r.pi_id || r.userId) === String(userId)
511
+ );
512
+ }
513
+
514
+ return null;
515
+ }
516
+
517
+ _processRatingsData(result, ratings, userId) {
518
+ if (!ratings) return;
519
+
520
+ const dates = Object.keys(ratings).sort();
521
+ let sumRatings = 0, countRatings = 0;
522
+
523
+ for (const dateStr of dates) {
524
+ const dayRatings = ratings[dateStr];
525
+ const userRating = this._findUserData(dayRatings, userId);
526
+
527
+ if (userRating) {
528
+ const avg = userRating.average_rating || userRating.avgRating || 0;
529
+ const total = userRating.total_ratings || userRating.totalRatings || 0;
530
+
531
+ result.ratingsData.ratingsOverTime.data.push({
532
+ date: dateStr,
533
+ rating: avg,
534
+ count: total
535
+ });
536
+
537
+ if (avg > 0) {
538
+ sumRatings += avg;
539
+ countRatings++;
540
+ }
541
+
542
+ result.ratingsData.totalRatings = total;
543
+ }
544
+ }
545
+
546
+ if (countRatings > 0) {
547
+ result.ratingsData.averageRating = Number((sumRatings / countRatings).toFixed(2));
548
+ }
549
+ }
550
+
551
+ _processPageViewsData(result, pageViews, userId) {
552
+ if (!pageViews) return;
553
+
554
+ const dates = Object.keys(pageViews).sort();
555
+
556
+ for (const dateStr of dates) {
557
+ const dayViews = pageViews[dateStr];
558
+ const userViews = this._findUserData(dayViews, userId);
559
+
560
+ if (userViews) {
561
+ const views = userViews.total_views || userViews.totalViews || 0;
562
+ const uniques = userViews.unique_viewers || userViews.uniqueViewers || 0;
563
+
564
+ result.pageViewsData.viewsOverTime.data.push({
565
+ date: dateStr,
566
+ views: views
567
+ });
568
+
569
+ result.pageViewsData.totalViews += views;
570
+ result.pageViewsData.uniqueViewers = uniques;
571
+ }
572
+ }
573
+ }
574
+
575
+ _processWatchlistData(result, watchlist, userId) {
576
+ if (!watchlist) return;
577
+
578
+ const dates = Object.keys(watchlist).sort();
579
+
580
+ for (const dateStr of dates) {
581
+ const dayWatchlist = watchlist[dateStr];
582
+ const userWatchlist = this._findUserData(dayWatchlist, userId);
583
+
584
+ if (userWatchlist) {
585
+ const total = userWatchlist.total_users || userWatchlist.totalUsers || 0;
586
+
587
+ result.watchlistData.watchlistOverTime.data.push({
588
+ date: dateStr,
589
+ count: total
590
+ });
591
+
592
+ result.watchlistData.totalUsers = total;
593
+ result.watchlistData.publicWatchlistCount = userWatchlist.public_watchlist_count || userWatchlist.publicCount || 0;
594
+ result.watchlistData.privateWatchlistCount = userWatchlist.private_watchlist_count || userWatchlist.privateCount || 0;
595
+ }
596
+ }
597
+ }
598
+
599
+ _processAlertHistory(result, alerts, userId) {
600
+ if (!alerts) return;
601
+
602
+ const dates = Object.keys(alerts).sort();
603
+ let totalAlertsLast7Days = 0;
604
+ const alertBreakdown = {};
605
+
606
+ for (const dateStr of dates) {
607
+ const dayAlerts = alerts[dateStr];
608
+ const userAlerts = this._findUserData(dayAlerts, userId);
609
+
610
+ if (userAlerts && typeof userAlerts === 'object') {
611
+ let dayCount = 0;
612
+
613
+ for (const [alertType, data] of Object.entries(userAlerts)) {
614
+ if (alertType.toLowerCase().includes('test')) continue;
615
+
616
+ const count = data.count || data.trigger_count || 1;
617
+ totalAlertsLast7Days += count;
618
+ dayCount += count;
619
+
620
+ alertBreakdown[alertType] = (alertBreakdown[alertType] || 0) + count;
621
+ }
622
+
623
+ if (dayCount > 0) {
624
+ result.alertHistoryData.alertCountsOverTime.data.push({
625
+ date: dateStr,
626
+ count: dayCount
627
+ });
628
+ }
629
+ }
630
+ }
631
+
632
+ result.alertMetrics.totalLast7Days = totalAlertsLast7Days;
633
+ result.alertMetrics.breakdown = alertBreakdown;
634
+
635
+ // Sort chart
636
+ result.alertHistoryData.alertCountsOverTime.data.sort((a, b) => a.date.localeCompare(b.date));
637
+ }
638
+
639
+ _processExposureOverTime(result, previousResult, currentDate) {
640
+ // Restore from previous day
641
+ if (previousResult) {
642
+ if (previousResult.sectorExposureOverTime?.data) {
643
+ result.sectorExposureOverTime.data = [...previousResult.sectorExposureOverTime.data];
644
+ }
645
+ if (previousResult.assetExposureOverTime?.data) {
646
+ result.assetExposureOverTime.data = [...previousResult.assetExposureOverTime.data];
647
+ }
648
+ }
649
+
650
+ // Add today's exposure
651
+ if (Object.keys(result.sectorExposure.data).length > 0) {
652
+ const todaySectorEntry = { date: currentDate, sectors: result.sectorExposure.data };
653
+ const idx = result.sectorExposureOverTime.data.findIndex(d => d.date === currentDate);
654
+ if (idx >= 0) {
655
+ result.sectorExposureOverTime.data[idx] = todaySectorEntry;
656
+ } else {
657
+ result.sectorExposureOverTime.data.push(todaySectorEntry);
658
+ }
659
+ }
660
+
661
+ if (Object.keys(result.assetExposure.data).length > 0) {
662
+ const todayAssetEntry = { date: currentDate, assets: result.assetExposure.data };
663
+ const idx = result.assetExposureOverTime.data.findIndex(d => d.date === currentDate);
664
+ if (idx >= 0) {
665
+ result.assetExposureOverTime.data[idx] = todayAssetEntry;
666
+ } else {
667
+ result.assetExposureOverTime.data.push(todayAssetEntry);
668
+ }
669
+ }
670
+
671
+ // Limit to 30 days
672
+ if (result.sectorExposureOverTime.data.length > 30) {
673
+ result.sectorExposureOverTime.data = result.sectorExposureOverTime.data.slice(-30);
674
+ }
675
+ if (result.assetExposureOverTime.data.length > 30) {
676
+ result.assetExposureOverTime.data = result.assetExposureOverTime.data.slice(-30);
677
+ }
678
+ }
679
+
680
+ // =========================================================================
681
+ // DATA EXTRACTION HELPERS
682
+ // =========================================================================
683
+
684
+ _extractPositions(portfolio) {
685
+ if (!portfolio) return [];
686
+ if (Array.isArray(portfolio)) return portfolio;
687
+ if (portfolio.AggregatedPositions) return portfolio.AggregatedPositions;
688
+ if (portfolio.Positions) return portfolio.Positions;
689
+ if (portfolio.positions) return portfolio.positions;
690
+ return [];
691
+ }
692
+
693
+ _extractTrades(historyDoc) {
694
+ if (!historyDoc) return [];
695
+ if (Array.isArray(historyDoc)) return historyDoc;
696
+ if (historyDoc.Trades) return historyDoc.Trades;
697
+ if (historyDoc.trades) return historyDoc.trades;
698
+ if (historyDoc.History) return historyDoc.History;
699
+ return [];
700
+ }
701
+
702
+ _findUserData(dayData, userId) {
703
+ if (!dayData) return null;
704
+
705
+ // Direct lookup by user ID
706
+ if (dayData[userId]) return dayData[userId];
707
+ if (dayData[String(userId)]) return dayData[String(userId)];
708
+
709
+ // Array - find by user ID field
710
+ if (Array.isArray(dayData)) {
711
+ return dayData.find(item =>
712
+ String(item.pi_id || item.user_id || item.userId) === String(userId)
713
+ );
714
+ }
715
+
716
+ return null;
717
+ }
718
+ }
719
+
720
+ module.exports = PopularInvestorProfileMetrics;