bulltrackers-module 1.0.766 → 1.0.769

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 (76) hide show
  1. package/functions/computation-system-v2/UserPortfolioMetrics.js +50 -0
  2. package/functions/computation-system-v2/computations/BehavioralAnomaly.js +559 -227
  3. package/functions/computation-system-v2/computations/GlobalAumPerAsset30D.js +103 -0
  4. package/functions/computation-system-v2/computations/NewSectorExposure.js +82 -35
  5. package/functions/computation-system-v2/computations/NewSocialPost.js +52 -24
  6. package/functions/computation-system-v2/computations/PIDailyAssetAUM.js +134 -0
  7. package/functions/computation-system-v2/computations/PiFeatureVectors.js +227 -0
  8. package/functions/computation-system-v2/computations/PiRecommender.js +359 -0
  9. package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +354 -641
  10. package/functions/computation-system-v2/computations/SignedInUserList.js +51 -0
  11. package/functions/computation-system-v2/computations/SignedInUserMirrorHistory.js +138 -0
  12. package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +106 -0
  13. package/functions/computation-system-v2/computations/SignedInUserProfileMetrics.js +324 -0
  14. package/functions/computation-system-v2/config/bulltrackers.config.js +40 -126
  15. package/functions/computation-system-v2/core-api.js +17 -9
  16. package/functions/computation-system-v2/data_schema_reference.MD +108 -0
  17. package/functions/computation-system-v2/devtools/builder/builder.js +362 -0
  18. package/functions/computation-system-v2/devtools/builder/examples/user-metrics.yaml +26 -0
  19. package/functions/computation-system-v2/devtools/index.js +36 -0
  20. package/functions/computation-system-v2/devtools/shared/MockDataFactory.js +235 -0
  21. package/functions/computation-system-v2/devtools/shared/SchemaTemplates.js +475 -0
  22. package/functions/computation-system-v2/devtools/shared/SystemIntrospector.js +517 -0
  23. package/functions/computation-system-v2/devtools/shared/index.js +16 -0
  24. package/functions/computation-system-v2/devtools/simulation/DAGAnalyzer.js +243 -0
  25. package/functions/computation-system-v2/devtools/simulation/MockDataFetcher.js +306 -0
  26. package/functions/computation-system-v2/devtools/simulation/MockStorageManager.js +336 -0
  27. package/functions/computation-system-v2/devtools/simulation/SimulationEngine.js +525 -0
  28. package/functions/computation-system-v2/devtools/simulation/SimulationServer.js +581 -0
  29. package/functions/computation-system-v2/devtools/simulation/index.js +17 -0
  30. package/functions/computation-system-v2/devtools/simulation/simulate.js +324 -0
  31. package/functions/computation-system-v2/devtools/vscode-computation/package.json +90 -0
  32. package/functions/computation-system-v2/devtools/vscode-computation/snippets/computation.json +128 -0
  33. package/functions/computation-system-v2/devtools/vscode-computation/src/extension.ts +401 -0
  34. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/codeActions.ts +152 -0
  35. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/completions.ts +207 -0
  36. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/diagnostics.ts +205 -0
  37. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/hover.ts +205 -0
  38. package/functions/computation-system-v2/devtools/vscode-computation/tsconfig.json +22 -0
  39. package/functions/computation-system-v2/docs/HowToCreateComputations.MD +602 -0
  40. package/functions/computation-system-v2/framework/core/Manifest.js +9 -16
  41. package/functions/computation-system-v2/framework/core/RunAnalyzer.js +2 -1
  42. package/functions/computation-system-v2/framework/data/DataFetcher.js +330 -126
  43. package/functions/computation-system-v2/framework/data/MaterializedViewManager.js +84 -0
  44. package/functions/computation-system-v2/framework/data/QueryBuilder.js +38 -38
  45. package/functions/computation-system-v2/framework/execution/Orchestrator.js +226 -153
  46. package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +17 -19
  47. package/functions/computation-system-v2/framework/storage/StateRepository.js +32 -2
  48. package/functions/computation-system-v2/framework/storage/StorageManager.js +111 -83
  49. package/functions/computation-system-v2/framework/testing/ComputationTester.js +161 -66
  50. package/functions/computation-system-v2/handlers/dispatcher.js +57 -29
  51. package/functions/computation-system-v2/legacy/PiAssetRecommender.js.old +115 -0
  52. package/functions/computation-system-v2/legacy/PiSimilarityMatrix.js +104 -0
  53. package/functions/computation-system-v2/legacy/PiSimilarityVector.js +71 -0
  54. package/functions/computation-system-v2/scripts/debug_aggregation.js +25 -0
  55. package/functions/computation-system-v2/scripts/test-computation-dag.js +109 -0
  56. package/functions/computation-system-v2/scripts/test-invalidation-scenarios.js +234 -0
  57. package/functions/task-engine/helpers/data_storage_helpers.js +6 -6
  58. package/package.json +1 -1
  59. package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +0 -176
  60. package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +0 -294
  61. package/functions/computation-system-v2/computations/UserPortfolioSummary.js +0 -172
  62. package/functions/computation-system-v2/scripts/migrate-sectors.js +0 -73
  63. package/functions/computation-system-v2/test/analyze-results.js +0 -238
  64. package/functions/computation-system-v2/test/other/test-dependency-cascade.js +0 -150
  65. package/functions/computation-system-v2/test/other/test-dispatcher.js +0 -317
  66. package/functions/computation-system-v2/test/other/test-framework.js +0 -500
  67. package/functions/computation-system-v2/test/other/test-real-execution.js +0 -166
  68. package/functions/computation-system-v2/test/other/test-real-integration.js +0 -194
  69. package/functions/computation-system-v2/test/other/test-refactor-e2e.js +0 -131
  70. package/functions/computation-system-v2/test/other/test-results.json +0 -31
  71. package/functions/computation-system-v2/test/other/test-risk-metrics-computation.js +0 -329
  72. package/functions/computation-system-v2/test/other/test-scheduler.js +0 -204
  73. package/functions/computation-system-v2/test/other/test-storage.js +0 -449
  74. package/functions/computation-system-v2/test/run-pipeline-test.js +0 -554
  75. package/functions/computation-system-v2/test/test-full-pipeline.js +0 -227
  76. package/functions/computation-system-v2/test/test-worker-pool.js +0 -266
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @fileoverview Aggregates $ AUM per asset across ALL Popular Investors.
3
+ * * STRATEGY: Global Computation (Reduce Step).
4
+ * * INPUT: Reads 'computation_results' for 'pidailyassetaum'.
5
+ * * OUTPUT: List of { symbol, amount } for Materialized View generation.
6
+ */
7
+ const { Computation } = require('../framework');
8
+
9
+ class GlobalAumPerAsset extends Computation {
10
+
11
+ static getConfig() {
12
+ return {
13
+ name: 'GlobalAumPerAsset',
14
+ type: 'global',
15
+ category: 'market_insights',
16
+
17
+ requires: {
18
+ 'computation_results': {
19
+ lookback: 0,
20
+ mandatory: true,
21
+ fields: ['result_data', 'computation_name', 'date'],
22
+ // Lowercase to match BigQuery storage
23
+ filter: {
24
+ computation_name: 'pidailyassetaum'
25
+ }
26
+ }
27
+ },
28
+
29
+ dependencies: ['PIDailyAssetAUM'],
30
+
31
+ storage: {
32
+ bigquery: true,
33
+ firestore: {
34
+ enabled: true,
35
+ path: 'global_metrics/aum_per_asset_{date}',
36
+ merge: true
37
+ }
38
+ }
39
+ };
40
+ }
41
+
42
+ async process(context) {
43
+ const { data } = context;
44
+
45
+ let rows = data['computation_results'];
46
+ if (!rows) {
47
+ console.log('[GlobalAumPerAsset] No input data found.');
48
+ return;
49
+ }
50
+
51
+ if (!Array.isArray(rows) && typeof rows === 'object') {
52
+ rows = Object.values(rows);
53
+ }
54
+
55
+ if (rows.length === 0) {
56
+ console.log('[GlobalAumPerAsset] Input array is empty.');
57
+ return;
58
+ }
59
+
60
+ console.log(`[GlobalAumPerAsset] Aggregating ${rows.length} PI portfolios...`);
61
+
62
+ const globalTotals = new Map();
63
+ let piCount = 0;
64
+
65
+ for (const row of rows) {
66
+ let assetMap = row.result_data;
67
+
68
+ if (typeof assetMap === 'string') {
69
+ try { assetMap = JSON.parse(assetMap); } catch (e) { continue; }
70
+ }
71
+
72
+ if (!assetMap) continue;
73
+
74
+ piCount++;
75
+
76
+ for (const [ticker, value] of Object.entries(assetMap)) {
77
+ if (typeof value === 'number') {
78
+ const current = globalTotals.get(ticker) || 0;
79
+ globalTotals.set(ticker, current + value);
80
+ }
81
+ }
82
+ }
83
+
84
+ const materializedViewData = [];
85
+ for (const [symbol, amount] of globalTotals.entries()) {
86
+ if (amount > 0) {
87
+ materializedViewData.push({
88
+ symbol: symbol,
89
+ amount: Number(amount.toFixed(2))
90
+ });
91
+ }
92
+ }
93
+
94
+ materializedViewData.sort((a, b) => b.amount - a.amount);
95
+
96
+ console.log(`[GlobalAumPerAsset] Generated stats for ${materializedViewData.length} unique assets from ${piCount} PIs.`);
97
+
98
+ // FIX: Use '_global' to match Orchestrator context (was 'global')
99
+ this.setResult('_global', materializedViewData);
100
+ }
101
+ }
102
+
103
+ module.exports = GlobalAumPerAsset;
@@ -1,17 +1,15 @@
1
+ /**
2
+ * @fileoverview New Sector Exposure Alert
3
+ * Refactored to map InstrumentID -> Ticker -> Sector (Industry)
4
+ * UPDATE: Uses "Last Known" logic for previous state (Lookback 14 days)
5
+ * Confirmed working 29/01/2026
6
+ */
1
7
  const { Computation } = require('../framework');
2
8
 
3
9
  class NewSectorExposure extends Computation {
4
10
 
5
11
  constructor() {
6
12
  super();
7
- this.SECTOR_MAPPING = {
8
- 1: 'Currencies',
9
- 2: 'Commodities',
10
- 4: 'Indices',
11
- 5: 'Stocks',
12
- 6: 'ETF',
13
- 10: 'Cryptocurrencies'
14
- };
15
13
  }
16
14
 
17
15
  static getConfig() {
@@ -23,9 +21,15 @@ class NewSectorExposure extends Computation {
23
21
 
24
22
  requires: {
25
23
  'portfolio_snapshots': {
26
- lookback: 1, // Today + Yesterday
24
+ lookback: 14, // INCREASED: Look back 2 weeks to find last known state
27
25
  mandatory: true,
28
26
  fields: ['user_id', 'portfolio_data', 'date']
27
+ },
28
+ 'ticker_mappings': {
29
+ mandatory: false
30
+ },
31
+ 'sector_mappings': {
32
+ mandatory: false
29
33
  }
30
34
  },
31
35
 
@@ -38,12 +42,11 @@ class NewSectorExposure extends Computation {
38
42
  }
39
43
  },
40
44
 
41
- // LEGACY METADATA
42
45
  userType: 'POPULAR_INVESTOR',
43
46
  alert: {
44
47
  id: 'newSector',
45
48
  frontendName: 'New Sector Entry',
46
- description: 'Alert when a Popular Investor enters a new sector category',
49
+ description: 'Alert when a Popular Investor enters a new industry sector',
47
50
  messageTemplate: '{piUsername} entered new sector(s): {sectorName}',
48
51
  severity: 'low',
49
52
  configKey: 'newSector',
@@ -55,7 +58,7 @@ class NewSectorExposure extends Computation {
55
58
  type: 'multiselect',
56
59
  label: 'Watch specific sectors',
57
60
  description: 'Only alert for these sectors',
58
- options: ['Stocks', 'Cryptocurrencies', 'ETF', 'Indices', 'Commodities', 'Currencies'],
61
+ options: ['Technology', 'Basic Materials', 'Consumer Cyclical', 'Financial Services', 'Real Estate', 'Healthcare', 'Energy', 'Industrials'],
59
62
  default: []
60
63
  }
61
64
  ],
@@ -70,49 +73,90 @@ class NewSectorExposure extends Computation {
70
73
  async process(context) {
71
74
  const { data, entityId, date, rules } = context;
72
75
 
73
- // 1. Get Data
76
+ // 1. Get Data Sources
74
77
  const history = data['portfolio_snapshots'] || [];
75
- const todayRow = history.find(d => d.date === date);
78
+ // FIX: Handle DataFetcher returning Objects (keyed by ID) or Arrays.
79
+ const tickerRows = Object.values(data['ticker_mappings'] || {});
80
+ const sectorRows = Object.values(data['sector_mappings'] || {});
81
+
82
+ // 2. Build Mapping Dictionaries
83
+ const tickerMap = new Map();
84
+ tickerRows.forEach(row => {
85
+ if (row.instrument_id && row.ticker) {
86
+ tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
87
+ }
88
+ });
89
+
90
+ const sectorMap = new Map();
91
+ sectorRows.forEach(row => {
92
+ if (row.symbol && row.sector && row.sector !== 'N/A') {
93
+ sectorMap.set(row.symbol.toUpperCase(), row.sector);
94
+ }
95
+ });
96
+
97
+ const resolveSector = (instrumentId) => {
98
+ const ticker = tickerMap.get(Number(instrumentId));
99
+ if (!ticker) return null;
100
+ return sectorMap.get(ticker);
101
+ };
102
+
103
+ // 3. Helper to handle BigQuery Dates
104
+ const toDateStr = (d) => {
105
+ if (!d) return null;
106
+ if (d.value) return d.value;
107
+ return d;
108
+ };
109
+
110
+ // 4. Find Today and Last Known Previous Row
111
+ // Sort history descending by date to find the most recent previous entry
112
+ history.sort((a, b) => {
113
+ const dateA = toDateStr(a.date);
114
+ const dateB = toDateStr(b.date);
115
+ return dateB.localeCompare(dateA); // Descending (Newest First)
116
+ });
117
+
118
+ const todayRow = history.find(d => toDateStr(d.date) === date);
76
119
 
77
- // Calc yesterday date
78
- const yDate = new Date(date);
79
- yDate.setDate(yDate.getDate() - 1);
80
- const yDateStr = yDate.toISOString().split('T')[0];
81
- const yesterdayRow = history.find(d => d.date === yDateStr);
120
+ // Find the first row where date is strictly less than target date
121
+ // Since we sorted descending, this is the "Last Known" date
122
+ const previousRow = history.find(d => toDateStr(d.date) < date);
82
123
 
83
- // 2. Extract Data
124
+ // 5. Extract Portfolio Data
84
125
  const todayData = rules.portfolio.extractPortfolioData(todayRow);
85
- const yesterdayData = rules.portfolio.extractPortfolioData(yesterdayRow);
126
+ const previousData = rules.portfolio.extractPortfolioData(previousRow);
86
127
 
87
128
  if (!todayData) return;
88
129
 
89
- // 3. Helper to get Sector Map
130
+ // 6. Calculate Sector Exposures
90
131
  const getSectorMap = (pData) => {
91
132
  const map = new Map();
92
- // Using Rule: extractPositionsByType gives AggregatedPositionsByInstrumentTypeID
93
- const aggs = rules.portfolio.extractPositionsByType(pData);
133
+ const positions = rules.portfolio.extractPositions(pData);
94
134
 
95
- aggs.forEach(agg => {
96
- const typeId = agg.InstrumentTypeID;
97
- const invested = agg.Invested || 0;
135
+ positions.forEach(pos => {
136
+ const instrumentId = rules.portfolio.getInstrumentId(pos);
137
+ const invested = rules.portfolio.getInvested(pos);
98
138
 
99
- if (typeId && invested > 0) {
100
- const sectorName = this.SECTOR_MAPPING[typeId];
101
- if (sectorName) map.set(sectorName, invested);
139
+ if (instrumentId && invested > 0) {
140
+ const sectorName = resolveSector(instrumentId);
141
+ if (sectorName) {
142
+ const current = map.get(sectorName) || 0;
143
+ map.set(sectorName, current + invested);
144
+ }
102
145
  }
103
146
  });
104
147
  return map;
105
148
  };
106
149
 
107
150
  const todaySectors = getSectorMap(todayData);
108
- const yesterdaySectors = getSectorMap(yesterdayData);
151
+ const previousSectors = getSectorMap(previousData);
109
152
 
153
+ // 7. Compare
110
154
  const newExposures = [];
111
- const isBaselineReset = (!yesterdayData);
155
+ const isBaselineReset = (!previousData); // True if no previous history found
112
156
 
113
157
  if (!isBaselineReset) {
114
158
  for (const [sectorName, invested] of todaySectors) {
115
- const prevInvested = yesterdaySectors.get(sectorName) || 0;
159
+ const prevInvested = previousSectors.get(sectorName) || 0;
116
160
  if (prevInvested === 0 && invested > 0) {
117
161
  newExposures.push(sectorName);
118
162
  }
@@ -121,11 +165,14 @@ class NewSectorExposure extends Computation {
121
165
 
122
166
  const result = {
123
167
  currentSectors: Array.from(todaySectors.keys()),
124
- previousSectors: isBaselineReset ? [] : Array.from(yesterdaySectors.keys()),
168
+ previousSectors: isBaselineReset ? [] : Array.from(previousSectors.keys()),
125
169
  newExposures,
126
170
  sectorName: newExposures.join(', '),
127
171
  isBaselineReset,
128
- triggered: newExposures.length > 0
172
+ triggered: newExposures.length > 0,
173
+ _metadata: {
174
+ previousDate: previousRow ? toDateStr(previousRow.date) : null
175
+ }
129
176
  };
130
177
 
131
178
  this.setResult(entityId, result);
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @fileoverview New Social Post Alert
3
+ * Logic: Alerts ONLY if a post exists with a creation date matching the current run date.
4
+ * Relies on DataFetcher V2.8+ to handle JSON parsing/double-encoding.
5
+ */
1
6
  const { Computation } = require('../framework');
2
7
 
3
8
  class NewSocialPost extends Computation {
@@ -7,11 +12,11 @@ class NewSocialPost extends Computation {
7
12
  name: 'NewSocialPost',
8
13
  type: 'per-entity',
9
14
  category: 'alerts',
10
- isHistorical: false,
15
+ isHistorical: true,
11
16
 
12
17
  requires: {
13
18
  'social_post_snapshots': {
14
- lookback: 0,
19
+ lookback: 5, // Short lookback just to ensure we catch the specific row
15
20
  mandatory: true,
16
21
  fields: ['user_id', 'posts_data', 'date']
17
22
  }
@@ -26,19 +31,15 @@ class NewSocialPost extends Computation {
26
31
  }
27
32
  },
28
33
 
29
- // LEGACY METADATA
30
34
  userType: 'POPULAR_INVESTOR',
31
35
  alert: {
32
36
  id: 'newSocialPost',
33
37
  frontendName: 'New Social Post',
34
- description: 'Alert when a Popular Investor makes a new social post',
38
+ description: 'Alert when a Popular Investor makes a new social post today',
35
39
  messageTemplate: '{piUsername} posted a new update: {title}',
36
40
  severity: 'low',
37
41
  configKey: 'newSocialPost',
38
- isDynamic: false,
39
- thresholds: [],
40
- conditions: [],
41
- resultKeys: ['hasNewPost', 'latestPostDate', 'postCount', 'title']
42
+ isDynamic: false
42
43
  }
43
44
  };
44
45
  }
@@ -46,50 +47,77 @@ class NewSocialPost extends Computation {
46
47
  async process(context) {
47
48
  const { data, entityId, date, rules } = context;
48
49
 
49
- // 1. Get Data using Rules
50
- // V2 data structure: data['table'][entityId] is the row (when lookback=0)
51
- const row = data['social_post_snapshots'];
52
- const posts = rules.social.extractPosts(row);
50
+ // 1. Get History
51
+ const history = data['social_post_snapshots'] || [];
52
+
53
+ // Helper: Handle BigQuery Date Objects
54
+ const toDateStr = (d) => {
55
+ if (!d) return null;
56
+ if (d.value) return d.value;
57
+ return d;
58
+ };
59
+
60
+ // 2. Find the Snapshot for "Today" (The Execution Date)
61
+ // We strictly want to know if they posted *on this specific date*.
62
+ const todayRow = history.find(d => toDateStr(d.date) === date);
63
+
64
+ if (!todayRow) {
65
+ // No snapshot exists for this date yet, so we can't determine if they posted.
66
+ this.setResult(entityId, {
67
+ hasNewPost: false,
68
+ triggered: false,
69
+ reason: "No snapshot found for date"
70
+ });
71
+ return;
72
+ }
73
+
74
+ // 3. Extract Posts
75
+ // (DataFetcher V2.8 automatically handles the double-encoded JSON)
76
+ const posts = rules.social.extractPosts(todayRow);
53
77
 
54
78
  let hasNewPost = false;
55
79
  let latestPost = null;
56
80
 
57
- // 2. Logic
81
+ // 4. Check for posts created "Today"
58
82
  for (const post of posts) {
59
- const postDate = rules.social.getPostDate(post);
60
- if (!postDate) continue;
83
+ const postDateObj = rules.social.getPostDate(post);
84
+ if (!postDateObj) continue;
85
+
86
+ // Format YYYY-MM-DD
87
+ const postDateStr = postDateObj.toISOString().slice(0, 10);
61
88
 
62
- // Check matches today (YYYY-MM-DD)
63
- if (postDate.toISOString().slice(0, 10) === date) {
89
+ if (postDateStr === date) {
64
90
  hasNewPost = true;
65
91
 
66
- const latestDate = latestPost ? rules.social.getPostDate(latestPost) : new Date(0);
67
- if (postDate > latestDate) {
92
+ // Track the latest one for the alert title
93
+ if (!latestPost || postDateObj > rules.social.getPostDate(latestPost)) {
68
94
  latestPost = post;
69
95
  }
70
96
  }
71
97
  }
72
98
 
73
- // 3. Title Extraction
99
+ // 5. Format Title for Alert
74
100
  let displayTitle = 'New Update';
75
101
  if (latestPost) {
76
102
  const text = rules.social.getPostText(latestPost);
77
- const rawTitle = latestPost.Title; // Fallback if rule doesn't cover Title specifically
103
+ const rawTitle = latestPost.Title;
78
104
 
79
105
  if (rawTitle) {
80
106
  displayTitle = rawTitle;
81
107
  } else if (text) {
82
- displayTitle = text.substring(0, 50).replace(/\n/g, ' ');
83
- if (text.length > 50) displayTitle += '...';
108
+ // Truncate text for display
109
+ displayTitle = text.substring(0, 60).replace(/\n/g, ' ');
110
+ if (text.length > 60) displayTitle += '...';
84
111
  }
85
112
  }
86
113
 
114
+ // 6. Set Result
87
115
  const result = {
88
116
  hasNewPost,
89
117
  latestPostDate: latestPost ? rules.social.getPostDate(latestPost) : null,
90
118
  postCount: posts.length,
91
119
  title: displayTitle,
92
- triggered: hasNewPost
120
+ triggered: hasNewPost // Only triggers if a post matched today's date
93
121
  };
94
122
 
95
123
  this.setResult(entityId, result);
@@ -0,0 +1,134 @@
1
+ const { Computation } = require('../framework');
2
+
3
+ class PIDailyAssetAUM extends Computation {
4
+
5
+ static getConfig() {
6
+ return {
7
+ name: 'PIDailyAssetAUM',
8
+ type: 'per-entity',
9
+ category: 'popular_investor',
10
+ isHistorical: true,
11
+
12
+ requires: {
13
+ // Driver: Only process Popular Investors
14
+ 'portfolio_snapshots': {
15
+ lookback: 30, // Extended lookback to ensure we find snapshots
16
+ mandatory: true,
17
+ fields: ['user_id', 'portfolio_data', 'date', 'user_type'],
18
+ filter: { user_type: 'POPULAR_INVESTOR' }
19
+ },
20
+ 'pi_rankings': {
21
+ lookback: 30, // Extended lookback to match portfolios
22
+ mandatory: true,
23
+ fields: ['pi_id', 'rankings_data', 'date']
24
+ },
25
+ 'ticker_mappings': {
26
+ mandatory: false,
27
+ fields: ['instrument_id', 'ticker']
28
+ },
29
+ 'pi_master_list': {
30
+ mandatory: false,
31
+ fields: ['cid', 'username']
32
+ }
33
+ },
34
+
35
+ storage: {
36
+ bigquery: true,
37
+ firestore: {
38
+ enabled: true,
39
+ path: 'popular_investors/{entityId}/metrics/asset_aum_{date}',
40
+ merge: true
41
+ }
42
+ }
43
+ };
44
+ }
45
+
46
+ async process(context) {
47
+ const { data, entityId, rules } = context;
48
+
49
+ // --- HELPER: Safe Date Parser ---
50
+ // Handles BigQuery { value: '2023-01-01' } objects to prevent NaN errors
51
+ const parseDate = (d) => {
52
+ if (!d) return null;
53
+ if (d instanceof Date) return d;
54
+ if (typeof d === 'object' && d.value) return new Date(d.value);
55
+ return new Date(d);
56
+ };
57
+
58
+ const getEntityRows = (dataset) => {
59
+ if (!dataset) return [];
60
+ if (dataset[entityId]) return Array.isArray(dataset[entityId]) ? dataset[entityId] : [dataset[entityId]];
61
+ // Handle mismatched ID columns (user_id vs pi_id)
62
+ if (Array.isArray(dataset)) return dataset.filter(r => String(r.user_id || r.pi_id || r.cid) === String(entityId));
63
+ return [];
64
+ };
65
+
66
+ // Mappings
67
+ const tickerMap = new Map();
68
+ const mappingsList = Array.isArray(context.data['ticker_mappings']) ? context.data['ticker_mappings'] : Object.values(context.data['ticker_mappings'] || {});
69
+ mappingsList.forEach(row => { if (row.instrument_id) tickerMap.set(Number(row.instrument_id), row.ticker); });
70
+ const resolveTicker = (id) => tickerMap.get(Number(id)) || `ID:${id}`;
71
+
72
+ // 2. Get Valid Snapshots
73
+ const portfolios = getEntityRows(data['portfolio_snapshots']);
74
+ const rankings = getEntityRows(data['pi_rankings']);
75
+
76
+ let validPortfolio = null;
77
+ let validRanking = null;
78
+
79
+ // Sort descending using safe date parsing
80
+ portfolios.sort((a,b) => parseDate(b.date) - parseDate(a.date));
81
+ rankings.sort((a,b) => parseDate(b.date) - parseDate(a.date));
82
+
83
+ if (portfolios.length > 0) {
84
+ validPortfolio = portfolios[0];
85
+ const pDate = parseDate(validPortfolio.date);
86
+
87
+ validRanking = rankings.find(r => {
88
+ const rDate = parseDate(r.date);
89
+ const diffTime = Math.abs(pDate - rDate);
90
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
91
+
92
+ // Allow up to 14 days gap (matches your data reality)
93
+ return diffDays <= 14;
94
+ });
95
+ }
96
+
97
+ if (!validPortfolio || !validRanking) {
98
+ return;
99
+ }
100
+
101
+ // 3. Calculation
102
+ const pData = rules.portfolio.extractPortfolioData(validPortfolio);
103
+ const rData = rules.rankings.extractRankingsData(validRanking);
104
+
105
+ // FIX: Use 'getAUM' (checked from rules/rankings.js)
106
+ const totalAum = rules.rankings.getAUM(rData);
107
+
108
+ if (!totalAum || totalAum <= 0) return;
109
+
110
+ const positions = rules.portfolio.extractPositions(pData);
111
+ const assetAumMap = {};
112
+
113
+ positions.forEach(pos => {
114
+ const id = rules.portfolio.getInstrumentId(pos);
115
+ const ticker = resolveTicker(id);
116
+
117
+ // FIX: Use 'getInvested' (checked from rules/portfolio.js)
118
+ const weight = rules.portfolio.getInvested(pos);
119
+
120
+ if (weight > 0) {
121
+ // weight is a percentage (e.g., 5.5 for 5.5%), so divide by 100
122
+ const dollarValue = (weight / 100) * totalAum;
123
+ assetAumMap[ticker] = (assetAumMap[ticker] || 0) + dollarValue;
124
+ }
125
+ });
126
+
127
+ // 4. Formatting
128
+ Object.keys(assetAumMap).forEach(k => assetAumMap[k] = Number(assetAumMap[k].toFixed(2)));
129
+
130
+ this.setResult(entityId, assetAumMap);
131
+ }
132
+ }
133
+
134
+ module.exports = PIDailyAssetAUM;