bulltrackers-module 1.0.765 → 1.0.768

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 (33) hide show
  1. package/functions/computation-system-v2/computations/BehavioralAnomaly.js +298 -186
  2. package/functions/computation-system-v2/computations/NewSectorExposure.js +82 -35
  3. package/functions/computation-system-v2/computations/NewSocialPost.js +52 -24
  4. package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +354 -641
  5. package/functions/computation-system-v2/config/bulltrackers.config.js +26 -14
  6. package/functions/computation-system-v2/framework/core/Manifest.js +9 -16
  7. package/functions/computation-system-v2/framework/core/RunAnalyzer.js +2 -1
  8. package/functions/computation-system-v2/framework/data/DataFetcher.js +142 -4
  9. package/functions/computation-system-v2/framework/execution/Orchestrator.js +119 -122
  10. package/functions/computation-system-v2/framework/storage/StorageManager.js +16 -18
  11. package/functions/computation-system-v2/framework/testing/ComputationTester.js +155 -66
  12. package/functions/computation-system-v2/handlers/scheduler.js +15 -5
  13. package/functions/computation-system-v2/scripts/test-computation-dag.js +109 -0
  14. package/functions/task-engine/helpers/data_storage_helpers.js +6 -6
  15. package/package.json +1 -1
  16. package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +0 -176
  17. package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +0 -294
  18. package/functions/computation-system-v2/computations/UserPortfolioSummary.js +0 -172
  19. package/functions/computation-system-v2/scripts/migrate-sectors.js +0 -73
  20. package/functions/computation-system-v2/test/analyze-results.js +0 -238
  21. package/functions/computation-system-v2/test/other/test-dependency-cascade.js +0 -150
  22. package/functions/computation-system-v2/test/other/test-dispatcher.js +0 -317
  23. package/functions/computation-system-v2/test/other/test-framework.js +0 -500
  24. package/functions/computation-system-v2/test/other/test-real-execution.js +0 -166
  25. package/functions/computation-system-v2/test/other/test-real-integration.js +0 -194
  26. package/functions/computation-system-v2/test/other/test-refactor-e2e.js +0 -131
  27. package/functions/computation-system-v2/test/other/test-results.json +0 -31
  28. package/functions/computation-system-v2/test/other/test-risk-metrics-computation.js +0 -329
  29. package/functions/computation-system-v2/test/other/test-scheduler.js +0 -204
  30. package/functions/computation-system-v2/test/other/test-storage.js +0 -449
  31. package/functions/computation-system-v2/test/run-pipeline-test.js +0 -554
  32. package/functions/computation-system-v2/test/test-full-pipeline.js +0 -227
  33. package/functions/computation-system-v2/test/test-worker-pool.js +0 -266
@@ -1,234 +1,162 @@
1
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
2
+ * @fileoverview Popular Investor Profile Metrics (V3.7 DATA ACCESS FIX)
3
+ * * FIX: Correctly accesses DataFetcher response maps.
4
+ * (Previously failed because it treated the Map-of-Arrays as a flat list of rows).
5
+ * * FIX: 'getEntityRows' helper now handles both Map lookups and flat fallbacks.
20
6
  */
21
-
22
7
  const { Computation } = require('../framework');
23
8
 
24
9
  class PopularInvestorProfileMetrics extends Computation {
25
-
26
- /**
27
- * v2 Configuration - Single source of truth for computation requirements.
28
- */
10
+
29
11
  static getConfig() {
30
12
  return {
31
13
  name: 'PopularInvestorProfileMetrics',
14
+ type: 'per-entity',
32
15
  category: 'popular_investor',
16
+ isHistorical: true,
33
17
 
34
- // Direct table references with lookback configuration
35
18
  requires: {
36
- // Core data - mandatory for execution
19
+ // --- Core Data ---
37
20
  'portfolio_snapshots': {
38
- lookback: 0,
21
+ lookback: 30,
39
22
  mandatory: true,
40
- filter: { user_type: 'POPULAR_INVESTOR' }
23
+ fields: ['user_id', 'portfolio_data', 'date']
41
24
  },
42
25
  'trade_history_snapshots': {
43
- lookback: 0,
44
- mandatory: true,
45
- filter: { user_type: 'POPULAR_INVESTOR' }
26
+ lookback: 30,
27
+ mandatory: false,
28
+ fields: ['user_id', 'history_data', 'date']
29
+ },
30
+ 'social_post_snapshots': {
31
+ lookback: 7,
32
+ mandatory: false,
33
+ fields: ['user_id', 'posts_data', 'date']
46
34
  },
47
35
 
48
- // Rankings - for AUM, risk score, gain, copiers
36
+ // --- Reference ---
37
+ 'pi_master_list': {
38
+ lookback: 1,
39
+ mandatory: false,
40
+ fields: ['cid', 'username']
41
+ },
42
+
43
+ // --- KPI Data ---
49
44
  'pi_rankings': {
50
- lookback: 7, // Self-healing: use latest available
51
- mandatory: false
45
+ lookback: 7,
46
+ mandatory: false,
47
+ fields: ['pi_id', 'rankings_data', 'username', 'date']
52
48
  },
53
-
54
- // Ratings - 30-day average
55
49
  'pi_ratings': {
56
50
  lookback: 30,
57
- mandatory: false
51
+ mandatory: false,
52
+ fields: ['pi_id', 'reviews', 'ratings_by_user', 'average_rating', 'total_ratings', 'date']
58
53
  },
59
-
60
- // Page views - 7-day accumulation
61
54
  'pi_page_views': {
62
- lookback: 7,
63
- mandatory: false
55
+ lookback: 14,
56
+ mandatory: false,
57
+ fields: ['pi_id', 'views_by_user', 'total_views', 'unique_viewers', 'date']
64
58
  },
65
-
66
- // Watchlist - 30-day trend
67
59
  'watchlist_membership': {
68
60
  lookback: 30,
69
- mandatory: false
61
+ mandatory: false,
62
+ fields: ['pi_id', 'total_users', 'date']
70
63
  },
71
-
72
- // Alert history - 7-day metrics
73
64
  'pi_alert_history': {
74
- lookback: 7,
75
- mandatory: false
76
- },
77
-
78
- // Social posts - 7-day engagement
79
- 'social_post_snapshots': {
80
65
  lookback: 7,
81
66
  mandatory: false,
82
- filter: { user_type: 'POPULAR_INVESTOR' }
67
+ fields: ['pi_id', 'alert_type', 'trigger_count', 'metadata', 'date']
83
68
  },
84
-
85
- // Ticker mappings for display names
86
- 'ticker_mappings': {
87
- lookback: 0, // Not date-partitioned
88
- mandatory: false
69
+
70
+ // --- Mappings ---
71
+ 'ticker_mappings': { mandatory: false },
72
+ 'sector_mappings': { mandatory: false }
73
+ },
74
+
75
+ storage: {
76
+ bigquery: true,
77
+ firestore: {
78
+ enabled: true,
79
+ path: 'profiles/{entityId}/metrics/{date}',
80
+ merge: true
89
81
  }
90
82
  },
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
- }
83
+
84
+ userType: 'POPULAR_INVESTOR'
127
85
  };
128
86
  }
129
-
130
- static getWeight() {
131
- return 10.0; // Complex per-user computation
132
- }
133
-
134
- /**
135
- * Process a single user's profile metrics.
136
- */
87
+
137
88
  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
-
89
+ const { data, entityId, date, rules } = context;
90
+
182
91
  // ==========================================================================================
183
- // 7. Rankings Data
92
+ // 0. PREPARATION & HELPER FUNCTIONS
184
93
  // ==========================================================================================
185
- this._processRankingsData(result, rankings, userId);
186
94
 
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;
95
+ const toDateStr = (d) => {
96
+ if (!d) return "";
97
+ if (d.value) return d.value;
98
+ return d instanceof Date ? d.toISOString().slice(0, 10) : String(d);
99
+ };
100
+
101
+ const toArray = (input) => {
102
+ if (!input) return [];
103
+ if (Array.isArray(input)) return input;
104
+ return Object.values(input);
105
+ };
106
+
107
+ // FIX: Robustly retrieve rows for THIS entity from any table structure
108
+ const getEntityRows = (dataset) => {
109
+ if (!dataset) return [];
110
+
111
+ // Case A: Map { "312723": [...] } (Standard DataFetcher behavior with entityField)
112
+ if (dataset[entityId]) {
113
+ const val = dataset[entityId];
114
+ return Array.isArray(val) ? val : [val];
194
115
  }
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);
116
+
117
+ // Case B: Flat Array [ row, row ] (Fallback for tables without entityField config)
118
+ if (Array.isArray(dataset)) {
119
+ // Check if the array actually contains rows or nested arrays (from bad Object.values)
120
+ if (dataset.length > 0 && Array.isArray(dataset[0])) {
121
+ // Flatten if we somehow got [[rows], [rows]]
122
+ return dataset.flat().filter(r => String(r.pi_id || r.user_id || r.cid) === String(entityId));
123
+ }
124
+ return dataset.filter(r => String(r.pi_id || r.user_id || r.cid) === String(entityId));
125
+ }
126
+
127
+ return [];
128
+ };
129
+
130
+ const sortAsc = (input) => {
131
+ const arr = toArray(input);
132
+ return arr.sort((a, b) => {
133
+ const dA = toDateStr(a.date);
134
+ const dB = toDateStr(b.date);
135
+ if (!dA) return 1;
136
+ if (!dB) return -1;
137
+ return dA.localeCompare(dB);
138
+ });
139
+ };
221
140
 
222
- // Store result
223
- this.setResult(userId, result);
224
- }
225
-
226
- // =========================================================================
227
- // PRIVATE METHODS
228
- // =========================================================================
229
-
230
- _initializeResult() {
231
- return {
141
+ // Mappings (Global, not per-entity)
142
+ const tickerMap = new Map();
143
+ toArray(data['ticker_mappings']).forEach(row => {
144
+ if (row.instrument_id && row.ticker) tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
145
+ });
146
+
147
+ const sectorMap = new Map();
148
+ toArray(data['sector_mappings']).forEach(row => {
149
+ if (row.symbol && row.sector) sectorMap.set(row.symbol.toUpperCase(), row.sector);
150
+ });
151
+
152
+ const resolveTicker = (id) => tickerMap.get(Number(id)) || `ID:${id}`;
153
+ const resolveSector = (id) => {
154
+ const ticker = tickerMap.get(Number(id));
155
+ return ticker ? (sectorMap.get(ticker) || 'Unknown') : 'Unknown';
156
+ };
157
+
158
+ // Initialize Result
159
+ const result = {
232
160
  username: null,
233
161
  socialEngagement: { chartType: 'line', data: [] },
234
162
  profitablePositions: { chartType: 'bar', data: [] },
@@ -238,483 +166,268 @@ class PopularInvestorProfileMetrics extends Computation {
238
166
  assetExposure: { chartType: 'pie', data: {} },
239
167
  portfolioSummary: { totalInvested: 0, totalProfit: 0, profitPercent: 0 },
240
168
  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
- },
169
+ ratingsData: { averageRating: 0, totalRatings: 0, ratingsOverTime: { chartType: 'line', data: [] } },
170
+ pageViewsData: { totalViews: 0, uniqueViewers: 0, viewsOverTime: { chartType: 'bar', data: [] } },
171
+ watchlistData: { totalUsers: 0, watchlistOverTime: { chartType: 'line', data: [] } },
257
172
  alertHistoryData: { triggeredAlerts: [], alertCountsOverTime: { chartType: 'bar', data: [] } },
258
173
  sectorExposureOverTime: { chartType: 'line', data: [] },
259
174
  assetExposureOverTime: { chartType: 'line', data: [] },
260
175
  alertMetrics: { totalLast7Days: 0, breakdown: {} }
261
176
  };
262
- }
263
-
264
- _buildMappings(tickerMappings) {
265
- const mappings = {
266
- instrumentToTicker: {},
267
- instrumentToSector: {}
268
- };
269
-
270
- if (!tickerMappings) return mappings;
177
+
178
+ // ==========================================================================================
179
+ // 1. DATA RETRIEVAL (Using Fixed 'getEntityRows')
180
+ // ==========================================================================================
271
181
 
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
- }
182
+ // These tables are keyed by 'user_id' (same as entityId) or 'pi_id' (same value)
183
+ const portfolios = sortAsc(getEntityRows(data['portfolio_snapshots']));
184
+ const historyData = sortAsc(getEntityRows(data['trade_history_snapshots']));
185
+ const socialData = sortAsc(getEntityRows(data['social_post_snapshots']));
186
+ const rankings = sortAsc(getEntityRows(data['pi_rankings']));
187
+ const ratings = sortAsc(getEntityRows(data['pi_ratings']));
188
+ const pageViews = sortAsc(getEntityRows(data['pi_page_views']));
189
+ const watchlists = sortAsc(getEntityRows(data['watchlist_membership']));
190
+ const alerts = sortAsc(getEntityRows(data['pi_alert_history']));
191
+ const masterList = getEntityRows(data['pi_master_list']);
192
+
193
+ const currentPortfolio = portfolios.length > 0 ? portfolios[portfolios.length - 1] : null;
194
+ const currentRanking = rankings.length > 0 ? rankings[rankings.length - 1] : null;
195
+
196
+ // ==========================================================================================
197
+ // 2. USERNAME
198
+ // ==========================================================================================
199
+ if (masterList.length > 0 && masterList[0].username) {
200
+ result.username = masterList[0].username;
201
+ } else if (currentRanking) {
202
+ result.username = currentRanking.username || currentRanking.UserName || null;
203
+ if (!result.username) {
204
+ const rData = rules.rankings.extractRankingsData(currentRanking);
205
+ result.username = rData?.UserName || null;
289
206
  }
290
207
  }
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 });
208
+
209
+ // ==========================================================================================
210
+ // 3. RANKINGS
211
+ // ==========================================================================================
212
+ if (currentRanking) {
213
+ const rData = rules.rankings.extractRankingsData(currentRanking);
214
+ if (rData) {
215
+ result.rankingsData = {
216
+ aum: rules.rankings.getAUMTier(rData),
217
+ riskScore: rules.rankings.getRiskScore(rData),
218
+ gain: rules.rankings.getTotalGain(rData),
219
+ copiers: rules.rankings.getCopiers(rData),
220
+ winRatio: rules.rankings.getWinRatio(rData),
221
+ trades: rules.rankings.getTotalTrades(rData)
222
+ };
320
223
  }
321
224
  }
322
- }
323
-
324
- _processProfitablePositions(result, historyDoc) {
325
- if (!historyDoc) return;
326
-
327
- const trades = this._extractTrades(historyDoc);
225
+
226
+ // ==========================================================================================
227
+ // 4. TRADES
228
+ // ==========================================================================================
328
229
  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
-
230
+ const allClosedTrades = [];
231
+
232
+ historyData.forEach(dayDoc => {
233
+ const trades = rules.trades.extractTrades(dayDoc);
234
+ trades.forEach(trade => {
235
+ const closeDate = rules.trades.getCloseDate(trade);
236
+ if (!closeDate) return;
237
+
238
+ const dKey = closeDate.toISOString().split('T')[0];
239
+ const profit = rules.trades.getNetProfit(trade);
240
+
241
+ const entry = profitableMap.get(dKey) || { date: dKey, profitableCount: 0, totalCount: 0 };
242
+ entry.totalCount++;
243
+ if (profit > 0) entry.profitableCount++;
244
+ profitableMap.set(dKey, entry);
245
+
246
+ allClosedTrades.push({
247
+ ticker: resolveTicker(rules.trades.getInstrumentId(trade)),
248
+ closeDate: closeDate,
249
+ netProfit: profit,
250
+ direction: rules.trades.isBuy(trade) ? 'Buy' : 'Sell'
251
+ });
252
+ });
253
+ });
254
+
347
255
  result.profitablePositions.data = Array.from(profitableMap.values())
348
256
  .sort((a, b) => a.date.localeCompare(b.date))
349
257
  .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
258
+
259
+ result.topWinningPositions.data = allClosedTrades
387
260
  .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;
261
+ .slice(0, 5)
262
+ .map(t => ({
263
+ ticker: t.ticker,
264
+ profit: Number(t.netProfit.toFixed(2)),
265
+ date: t.closeDate.toISOString().slice(0, 10),
266
+ direction: t.direction
267
+ }));
268
+
269
+ // ==========================================================================================
270
+ // 5. PORTFOLIO & SECTOR EXPOSURE
271
+ // ==========================================================================================
272
+ if (currentPortfolio) {
273
+ const pData = rules.portfolio.extractPortfolioData(currentPortfolio);
274
+ const positions = rules.portfolio.extractPositions(pData);
451
275
 
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));
276
+ let totalInvested = 0, totalProfit = 0;
277
+ const secMap = {};
278
+ const assetMap = {};
279
+ const sectorProfits = {};
280
+
281
+ positions.forEach(pos => {
282
+ const id = rules.portfolio.getInstrumentId(pos);
283
+ const invested = rules.portfolio.getInvested(pos);
284
+ const netProfit = rules.portfolio.getNetProfit(pos);
285
+
286
+ totalInvested += invested;
287
+ totalProfit += (invested * (netProfit / 100));
288
+
289
+ const ticker = resolveTicker(id);
290
+ const sector = resolveSector(id);
291
+
292
+ assetMap[ticker] = (assetMap[ticker] || 0) + invested;
293
+ secMap[sector] = (secMap[sector] || 0) + invested;
294
+
295
+ if (!sectorProfits[sector]) sectorProfits[sector] = { profit: 0, weight: 0 };
296
+ sectorProfits[sector].profit += (invested * (netProfit / 100));
297
+ sectorProfits[sector].weight += invested;
459
298
  });
460
-
299
+
300
+ result.portfolioSummary = {
301
+ totalInvested: Number(totalInvested.toFixed(2)),
302
+ totalProfit: Number(totalProfit.toFixed(2)),
303
+ profitPercent: totalInvested > 0 ? Number(((totalProfit / totalInvested) * 100).toFixed(2)) : 0
304
+ };
305
+
461
306
  Object.entries(assetMap)
462
- .sort(([, a], [, b]) => b - a)
307
+ .sort(([,a], [,b]) => b - a)
463
308
  .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
- }
309
+ .forEach(([k, v]) => result.assetExposure.data[k] = Number(v.toFixed(2)));
310
+
311
+ Object.entries(secMap)
312
+ .forEach(([k, v]) => result.sectorExposure.data[k] = Number(v.toFixed(2)));
313
+
314
+ let bestS = null, worstS = null, bestP = -Infinity, worstP = Infinity;
315
+ Object.entries(sectorProfits).forEach(([sec, d]) => {
316
+ if (d.weight <= 0) return;
317
+ const p = (d.profit / d.weight) * 100;
318
+ if (p > bestP) { bestP = p; bestS = sec; }
319
+ if (p < worstP) { worstP = p; worstS = sec; }
320
+ });
321
+ result.sectorPerformance = {
322
+ bestSector: bestS, bestSectorProfit: bestS ? Number(bestP.toFixed(2)) : 0,
323
+ worstSector: worstS, worstSectorProfit: worstS ? Number(worstP.toFixed(2)) : 0
324
+ };
572
325
  }
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);
326
+
327
+ // ==========================================================================================
328
+ // 6. EXPOSURE OVER TIME
329
+ // ==========================================================================================
330
+ portfolios.forEach(entry => {
331
+ const pData = rules.portfolio.extractPortfolioData(entry);
332
+ const positions = rules.portfolio.extractPositions(pData);
333
+ const dateStr = toDateStr(entry.date);
583
334
 
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);
335
+ const dailySec = {};
336
+ const dailyAsset = {};
609
337
 
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];
338
+ positions.forEach(pos => {
339
+ const id = rules.portfolio.getInstrumentId(pos);
340
+ const invested = rules.portfolio.getInvested(pos);
341
+ const ticker = resolveTicker(id);
342
+ const sector = resolveSector(id);
343
+ dailySec[sector] = (dailySec[sector] || 0) + invested;
344
+ dailyAsset[ticker] = (dailyAsset[ticker] || 0) + invested;
345
+ });
346
+
347
+ result.sectorExposureOverTime.data.push({ date: dateStr, sectors: dailySec });
348
+ result.assetExposureOverTime.data.push({ date: dateStr, assets: dailyAsset });
349
+ });
350
+
351
+ // ==========================================================================================
352
+ // 7. SOCIAL, RATINGS, PAGEVIEWS, WATCHLIST
353
+ // ==========================================================================================
354
+ const sevenDaysAgo = new Date(new Date(date).setDate(new Date(date).getDate() - 7)).toISOString().slice(0,10);
355
+
356
+ socialData.filter(d => toDateStr(d.date) >= sevenDaysAgo).forEach(day => {
357
+ const posts = rules.social.extractPosts(day);
358
+ let likes = 0, comments = 0;
359
+ posts.forEach(p => {
360
+ likes += (rules.social.getPostLikes(p) || 0);
361
+ comments += (rules.social.getPostComments(p) || 0);
362
+ });
363
+ result.socialEngagement.data.push({ date: toDateStr(day.date), likes, comments });
364
+ });
365
+
366
+ ratings.forEach(r => {
367
+ const val = Number(r.average_rating || 0);
368
+ const count = Number(r.total_ratings || 0);
369
+ const d = toDateStr(r.date);
370
+ if (d) {
371
+ result.ratingsData.ratingsOverTime.data.push({ date: d, rating: val, count });
372
+ result.ratingsData.totalRatings = count;
373
+ result.ratingsData.averageRating = val;
644
374
  }
645
- if (previousResult.assetExposureOverTime?.data) {
646
- result.assetExposureOverTime.data = [...previousResult.assetExposureOverTime.data];
375
+ });
376
+
377
+ pageViews.forEach(p => {
378
+ const views = Number(p.total_views || 0);
379
+ const d = toDateStr(p.date);
380
+ if (d) {
381
+ result.pageViewsData.viewsOverTime.data.push({ date: d, views });
382
+ result.pageViewsData.totalViews += views;
383
+ result.pageViewsData.uniqueViewers = Number(p.unique_viewers || 0);
647
384
  }
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);
385
+ });
386
+
387
+ watchlists.forEach(w => {
388
+ const count = Number(w.total_users || 0);
389
+ const d = toDateStr(w.date);
390
+ if (d) {
391
+ result.watchlistData.watchlistOverTime.data.push({ date: d, count });
392
+ result.watchlistData.totalUsers = count;
658
393
  }
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);
394
+ });
395
+
396
+ // ==========================================================================================
397
+ // 8. ALERTS HISTORY
398
+ // ==========================================================================================
399
+ const alertsWindow = alerts.filter(d => toDateStr(d.date) >= sevenDaysAgo);
400
+ const alertsByDate = {};
401
+
402
+ alertsWindow.forEach(row => {
403
+ const d = toDateStr(row.date);
404
+ const type = row.alert_type;
405
+ const count = Number(row.trigger_count || 1);
406
+
407
+ if (type && type.toLowerCase().includes('test')) return;
408
+
409
+ result.alertMetrics.totalLast7Days += count;
410
+ result.alertMetrics.breakdown[type] = (result.alertMetrics.breakdown[type] || 0) + count;
411
+ alertsByDate[d] = (alertsByDate[d] || 0) + count;
412
+
413
+ if (d === date) {
414
+ result.alertHistoryData.triggeredAlerts.push({
415
+ alertType: type,
416
+ triggered: true,
417
+ count: count,
418
+ metadata: row.metadata || {}
419
+ });
668
420
  }
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;
421
+ });
422
+
423
+ Object.entries(alertsByDate)
424
+ .sort((a, b) => a[0].localeCompare(b[0]))
425
+ .forEach(([d, count]) => {
426
+ result.alertHistoryData.alertCountsOverTime.data.push({ date: d, count });
427
+ });
428
+
429
+ this.setResult(entityId, result);
717
430
  }
718
431
  }
719
432
 
720
- module.exports = PopularInvestorProfileMetrics;
433
+ module.exports = PopularInvestorProfileMetrics;