bulltrackers-module 1.0.768 → 1.0.770

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 (52) hide show
  1. package/functions/computation-system-v2/UserPortfolioMetrics.js +50 -0
  2. package/functions/computation-system-v2/computations/BehavioralAnomaly.js +557 -337
  3. package/functions/computation-system-v2/computations/GlobalAumPerAsset30D.js +103 -0
  4. package/functions/computation-system-v2/computations/PIDailyAssetAUM.js +134 -0
  5. package/functions/computation-system-v2/computations/PiFeatureVectors.js +227 -0
  6. package/functions/computation-system-v2/computations/PiRecommender.js +359 -0
  7. package/functions/computation-system-v2/computations/RiskScoreIncrease.js +13 -13
  8. package/functions/computation-system-v2/computations/SignedInUserMirrorHistory.js +138 -0
  9. package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +106 -0
  10. package/functions/computation-system-v2/computations/SignedInUserProfileMetrics.js +324 -0
  11. package/functions/computation-system-v2/config/bulltrackers.config.js +30 -128
  12. package/functions/computation-system-v2/core-api.js +17 -9
  13. package/functions/computation-system-v2/data_schema_reference.MD +108 -0
  14. package/functions/computation-system-v2/devtools/builder/builder.js +362 -0
  15. package/functions/computation-system-v2/devtools/builder/examples/user-metrics.yaml +26 -0
  16. package/functions/computation-system-v2/devtools/index.js +36 -0
  17. package/functions/computation-system-v2/devtools/shared/MockDataFactory.js +235 -0
  18. package/functions/computation-system-v2/devtools/shared/SchemaTemplates.js +475 -0
  19. package/functions/computation-system-v2/devtools/shared/SystemIntrospector.js +517 -0
  20. package/functions/computation-system-v2/devtools/shared/index.js +16 -0
  21. package/functions/computation-system-v2/devtools/simulation/DAGAnalyzer.js +243 -0
  22. package/functions/computation-system-v2/devtools/simulation/MockDataFetcher.js +306 -0
  23. package/functions/computation-system-v2/devtools/simulation/MockStorageManager.js +336 -0
  24. package/functions/computation-system-v2/devtools/simulation/SimulationEngine.js +525 -0
  25. package/functions/computation-system-v2/devtools/simulation/SimulationServer.js +581 -0
  26. package/functions/computation-system-v2/devtools/simulation/index.js +17 -0
  27. package/functions/computation-system-v2/devtools/simulation/simulate.js +324 -0
  28. package/functions/computation-system-v2/devtools/vscode-computation/package.json +90 -0
  29. package/functions/computation-system-v2/devtools/vscode-computation/snippets/computation.json +128 -0
  30. package/functions/computation-system-v2/devtools/vscode-computation/src/extension.ts +401 -0
  31. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/codeActions.ts +152 -0
  32. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/completions.ts +207 -0
  33. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/diagnostics.ts +205 -0
  34. package/functions/computation-system-v2/devtools/vscode-computation/src/providers/hover.ts +205 -0
  35. package/functions/computation-system-v2/devtools/vscode-computation/tsconfig.json +22 -0
  36. package/functions/computation-system-v2/docs/HowToCreateComputations.MD +602 -0
  37. package/functions/computation-system-v2/framework/data/DataFetcher.js +250 -184
  38. package/functions/computation-system-v2/framework/data/MaterializedViewManager.js +84 -0
  39. package/functions/computation-system-v2/framework/data/QueryBuilder.js +38 -38
  40. package/functions/computation-system-v2/framework/execution/Orchestrator.js +215 -129
  41. package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +17 -19
  42. package/functions/computation-system-v2/framework/storage/StateRepository.js +32 -2
  43. package/functions/computation-system-v2/framework/storage/StorageManager.js +105 -67
  44. package/functions/computation-system-v2/framework/testing/ComputationTester.js +12 -6
  45. package/functions/computation-system-v2/handlers/dispatcher.js +57 -29
  46. package/functions/computation-system-v2/handlers/scheduler.js +172 -203
  47. package/functions/computation-system-v2/legacy/PiAssetRecommender.js.old +115 -0
  48. package/functions/computation-system-v2/legacy/PiSimilarityMatrix.js +104 -0
  49. package/functions/computation-system-v2/legacy/PiSimilarityVector.js +71 -0
  50. package/functions/computation-system-v2/scripts/debug_aggregation.js +25 -0
  51. package/functions/computation-system-v2/scripts/test-invalidation-scenarios.js +234 -0
  52. package/package.json +1 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * @fileoverview PI Recommender (v2 - Diversification-Aware)
3
+ *
4
+ * Recommends Popular Investors to signed-in users based on:
5
+ * 1. User preferences (sectors, risk tolerance they're drawn to)
6
+ * 2. Diversification benefit (low correlation with current portfolio)
7
+ * 3. Quality thresholds (risk score, copiers, track record)
8
+ *
9
+ * This replaces the legacy similarity-based approach which was harmful
10
+ * because it doubled down on existing exposure instead of diversifying.
11
+ */
12
+ const { Computation } = require('../framework');
13
+
14
+ class PiRecommender extends Computation {
15
+ static getConfig() {
16
+ return {
17
+ name: 'PiRecommender',
18
+ description: 'Recommends PIs balancing user preferences with diversification',
19
+ type: 'per-entity',
20
+ category: 'signed_in_user',
21
+ isHistorical: false,
22
+
23
+ // Depends on global PI feature vectors
24
+ dependencies: ['PiFeatureVectors'],
25
+
26
+ requires: {
27
+ 'portfolio_snapshots': {
28
+ lookback: 0,
29
+ mandatory: true,
30
+ fields: ['user_id', 'portfolio_data', 'date'],
31
+ filter: { user_type: 'SIGNED_IN_USER' }
32
+ },
33
+ 'ticker_mappings': { mandatory: false },
34
+ 'sector_mappings': { mandatory: false }
35
+ },
36
+
37
+ storage: {
38
+ bigquery: true,
39
+ firestore: {
40
+ enabled: true,
41
+ path: 'users/{entityId}/recommendations',
42
+ merge: true
43
+ }
44
+ }
45
+ };
46
+ }
47
+
48
+ async process(context) {
49
+ const { data, entityId, rules, getDependency, targetDate } = context;
50
+
51
+ // ===========================================================================
52
+ // CONFIGURATION: Scoring weights and thresholds
53
+ // ===========================================================================
54
+ const WEIGHTS = {
55
+ interest: 0.30, // How much they match user preferences
56
+ diversification: 0.40, // How different they are (highest weight!)
57
+ quality: 0.30 // Performance and popularity
58
+ };
59
+
60
+ const THRESHOLDS = {
61
+ minCopiers: 10, // Skip unpopular PIs
62
+ maxRiskScore: 8, // Skip very high risk
63
+ minGain: -10, // Skip extreme losers
64
+ excludeAlreadyCopied: true
65
+ };
66
+
67
+ const TOP_N = 10; // Return top N recommendations
68
+
69
+ // ===========================================================================
70
+ // SETUP: Build sector lookup and helpers
71
+ // ===========================================================================
72
+ const toArray = (input) => {
73
+ if (!input) return [];
74
+ if (Array.isArray(input)) return input;
75
+ return Object.values(input).flat();
76
+ };
77
+
78
+ const tickerMap = new Map();
79
+ toArray(data['ticker_mappings']).forEach(row => {
80
+ if (row.instrument_id && row.ticker) {
81
+ tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
82
+ }
83
+ });
84
+
85
+ const sectorMap = new Map();
86
+ toArray(data['sector_mappings']).forEach(row => {
87
+ if (row.symbol && row.sector) {
88
+ sectorMap.set(row.symbol.toUpperCase(), row.sector);
89
+ }
90
+ });
91
+
92
+ const resolveSector = (instrumentId) => {
93
+ const ticker = tickerMap.get(Number(instrumentId));
94
+ return ticker ? (sectorMap.get(ticker) || 'Other') : 'Other';
95
+ };
96
+
97
+ // ===========================================================================
98
+ // STEP 1: Get user's current portfolio profile
99
+ // ===========================================================================
100
+ const portfolioRows = data['portfolio_snapshots']?.[entityId];
101
+ if (!portfolioRows || portfolioRows.length === 0) {
102
+ this.setResult(entityId, { recommendations: [], reason: 'No portfolio data' });
103
+ return;
104
+ }
105
+
106
+ const latestRow = Array.isArray(portfolioRows)
107
+ ? portfolioRows[portfolioRows.length - 1]
108
+ : portfolioRows;
109
+
110
+ const pData = rules.portfolio.extractPortfolioData(latestRow);
111
+ const positions = rules.portfolio.extractPositions(pData);
112
+ const mirrors = pData.AggregatedMirrors || [];
113
+
114
+ // Already copied PI IDs
115
+ const copiedPiIds = new Set(
116
+ mirrors.map(m => String(m.ParentCID || m.CID)).filter(Boolean)
117
+ );
118
+
119
+ // Build user's sector exposure
120
+ const userSectorWeights = {};
121
+ let userTotalValue = 0;
122
+
123
+ positions.forEach(pos => {
124
+ const instrumentId = rules.portfolio.getInstrumentId(pos);
125
+ const invested = rules.portfolio.getInvested(pos) || 0;
126
+ const sector = resolveSector(instrumentId);
127
+
128
+ userSectorWeights[sector] = (userSectorWeights[sector] || 0) + invested;
129
+ userTotalValue += invested;
130
+ });
131
+
132
+ // Include mirror exposures
133
+ mirrors.forEach(m => {
134
+ userTotalValue += (m.Invested || m.Amount || 0);
135
+ });
136
+
137
+ // Normalize to percentages
138
+ const userSectorProfile = {};
139
+ Object.entries(userSectorWeights).forEach(([sector, weight]) => {
140
+ userSectorProfile[sector] = userTotalValue > 0
141
+ ? weight / userTotalValue
142
+ : 0;
143
+ });
144
+
145
+ // Infer user preferences from copied PIs
146
+ const userPreferredRiskRange = this._inferRiskPreference(mirrors, rules);
147
+
148
+ // ===========================================================================
149
+ // STEP 2: Load global PI feature vectors
150
+ // ===========================================================================
151
+ const piFeatureData = getDependency('PiFeatureVectors');
152
+ if (!piFeatureData || !piFeatureData.vectors) {
153
+ this.setResult(entityId, { recommendations: [], reason: 'No PI features available' });
154
+ return;
155
+ }
156
+
157
+ const piVectors = piFeatureData.vectors;
158
+ const allPiIds = Object.keys(piVectors);
159
+
160
+ // ===========================================================================
161
+ // STEP 3: Score each candidate PI
162
+ // ===========================================================================
163
+ const scoredCandidates = [];
164
+
165
+ for (const piId of allPiIds) {
166
+ // Skip already copied
167
+ if (THRESHOLDS.excludeAlreadyCopied && copiedPiIds.has(piId)) {
168
+ continue;
169
+ }
170
+
171
+ const piFv = piVectors[piId];
172
+
173
+ // Apply quality thresholds
174
+ if (piFv.copiers < THRESHOLDS.minCopiers) continue;
175
+ if (piFv.riskScore > THRESHOLDS.maxRiskScore) continue;
176
+ if (piFv.gain < THRESHOLDS.minGain) continue;
177
+
178
+ // Calculate scores
179
+ const interestScore = this._calcInterestScore(piFv, userSectorProfile, userPreferredRiskRange);
180
+ const diversificationScore = this._calcDiversificationScore(piFv, userSectorProfile);
181
+ const qualityScore = this._calcQualityScore(piFv);
182
+
183
+ const totalScore =
184
+ (interestScore * WEIGHTS.interest) +
185
+ (diversificationScore * WEIGHTS.diversification) +
186
+ (qualityScore * WEIGHTS.quality);
187
+
188
+ scoredCandidates.push({
189
+ piId: piFv.piId,
190
+ username: piFv.username,
191
+ score: Number(totalScore.toFixed(4)),
192
+ breakdown: {
193
+ interest: Number(interestScore.toFixed(3)),
194
+ diversification: Number(diversificationScore.toFixed(3)),
195
+ quality: Number(qualityScore.toFixed(3))
196
+ },
197
+ // Include key metrics for frontend
198
+ metrics: {
199
+ riskScore: piFv.riskScore,
200
+ gain: piFv.gain,
201
+ copiers: piFv.copiers,
202
+ winRatio: piFv.winRatio,
203
+ topSectors: this._getTopSectors(piFv.sectors, 3)
204
+ },
205
+ reason: this._generateReason(diversificationScore, interestScore, piFv)
206
+ });
207
+ }
208
+
209
+ // ===========================================================================
210
+ // STEP 4: Rank and return top N
211
+ // ===========================================================================
212
+ const recommendations = scoredCandidates
213
+ .sort((a, b) => b.score - a.score)
214
+ .slice(0, TOP_N);
215
+
216
+ this.setResult(entityId, {
217
+ recommendations,
218
+ userProfile: {
219
+ topSectors: this._getTopSectors(userSectorProfile, 3),
220
+ preferredRiskRange: userPreferredRiskRange,
221
+ currentCopies: copiedPiIds.size
222
+ },
223
+ metadata: {
224
+ candidatesScored: scoredCandidates.length,
225
+ totalPIs: allPiIds.length,
226
+ computedAt: targetDate
227
+ }
228
+ });
229
+ }
230
+
231
+ // ===========================================================================
232
+ // SCORING FUNCTIONS
233
+ // ===========================================================================
234
+
235
+ /**
236
+ * Interest score: How well does the PI match user preferences?
237
+ * Higher = more aligned with sectors/risk they seem to like
238
+ */
239
+ _calcInterestScore(piFv, userSectorProfile, preferredRiskRange) {
240
+ // Sector overlap (dot product of normalized vectors)
241
+ let sectorOverlap = 0;
242
+ Object.entries(userSectorProfile).forEach(([sector, userWeight]) => {
243
+ const piWeight = piFv.sectors[sector] || 0;
244
+ sectorOverlap += userWeight * piWeight;
245
+ });
246
+
247
+ // Risk preference match (1 = perfect match, 0 = way off)
248
+ const riskMatch = preferredRiskRange.min !== null
249
+ ? 1 - Math.min(1, Math.abs(piFv.riskScore - preferredRiskRange.avg) / 5)
250
+ : 0.5; // Neutral if no preference detected
251
+
252
+ return (sectorOverlap * 0.6) + (riskMatch * 0.4);
253
+ }
254
+
255
+ /**
256
+ * Diversification score: How different is the PI from current portfolio?
257
+ * Higher = more different = better for diversification
258
+ */
259
+ _calcDiversificationScore(piFv, userSectorProfile) {
260
+ // Calculate sector distance (1 - overlap)
261
+ let overlap = 0;
262
+ let userMagnitude = 0;
263
+ let piMagnitude = 0;
264
+
265
+ Object.keys(piFv.sectors).forEach(sector => {
266
+ const userWeight = userSectorProfile[sector] || 0;
267
+ const piWeight = piFv.sectors[sector] || 0;
268
+
269
+ overlap += userWeight * piWeight;
270
+ userMagnitude += userWeight * userWeight;
271
+ piMagnitude += piWeight * piWeight;
272
+ });
273
+
274
+ userMagnitude = Math.sqrt(userMagnitude);
275
+ piMagnitude = Math.sqrt(piMagnitude);
276
+
277
+ // Cosine similarity → distance
278
+ const cosineSim = (userMagnitude > 0 && piMagnitude > 0)
279
+ ? overlap / (userMagnitude * piMagnitude)
280
+ : 0;
281
+
282
+ const diversityScore = 1 - cosineSim;
283
+
284
+ // Bonus for low concentration (well-diversified PI)
285
+ const concentrationBonus = (1 - piFv.concentration) * 0.3;
286
+
287
+ return Math.min(1, diversityScore + concentrationBonus);
288
+ }
289
+
290
+ /**
291
+ * Quality score: How good is the PI objectively?
292
+ * Combines performance, popularity, and track record
293
+ */
294
+ _calcQualityScore(piFv) {
295
+ // Normalize gain to 0-1 scale (-50% to +100% typical range)
296
+ const gainNorm = Math.max(0, Math.min(1, (piFv.gain + 50) / 150));
297
+
298
+ // Win ratio already 0-100, normalize
299
+ const winRatioNorm = piFv.winRatio / 100;
300
+
301
+ // Copiers (log scale, cap at ~10k)
302
+ const copiersNorm = Math.min(1, Math.log10(Math.max(1, piFv.copiers)) / 4);
303
+
304
+ // Risk-adjusted (lower risk = bonus)
305
+ const riskBonus = (10 - piFv.riskScore) / 10 * 0.2;
306
+
307
+ return (gainNorm * 0.35) + (winRatioNorm * 0.25) + (copiersNorm * 0.2) + riskBonus;
308
+ }
309
+
310
+ // ===========================================================================
311
+ // HELPERS
312
+ // ===========================================================================
313
+
314
+ _inferRiskPreference(mirrors, rules) {
315
+ if (!mirrors || mirrors.length === 0) {
316
+ return { min: null, max: null, avg: null };
317
+ }
318
+
319
+ const riskScores = mirrors
320
+ .map(m => m.RiskScore || 5)
321
+ .filter(r => r > 0 && r <= 10);
322
+
323
+ if (riskScores.length === 0) {
324
+ return { min: null, max: null, avg: null };
325
+ }
326
+
327
+ return {
328
+ min: Math.min(...riskScores),
329
+ max: Math.max(...riskScores),
330
+ avg: riskScores.reduce((a, b) => a + b, 0) / riskScores.length
331
+ };
332
+ }
333
+
334
+ _getTopSectors(sectorWeights, n) {
335
+ return Object.entries(sectorWeights)
336
+ .filter(([_, weight]) => weight > 0.01)
337
+ .sort((a, b) => b[1] - a[1])
338
+ .slice(0, n)
339
+ .map(([sector, weight]) => ({
340
+ sector,
341
+ weight: Number((weight * 100).toFixed(1))
342
+ }));
343
+ }
344
+
345
+ _generateReason(divScore, intScore, piFv) {
346
+ if (divScore > 0.7) {
347
+ return `Diversifies your portfolio with ${this._getTopSectors(piFv.sectors, 1)[0]?.sector || 'different'} exposure`;
348
+ }
349
+ if (intScore > 0.6) {
350
+ return `Matches your investing style with ${piFv.gain.toFixed(0)}% returns`;
351
+ }
352
+ if (piFv.copiers > 1000) {
353
+ return `Popular choice with ${piFv.copiers.toLocaleString()} copiers`;
354
+ }
355
+ return `Balanced recommendation with ${piFv.winRatio}% win ratio`;
356
+ }
357
+ }
358
+
359
+ module.exports = PiRecommender;
@@ -6,17 +6,17 @@ class RiskScoreIncrease extends Computation {
6
6
  return {
7
7
  name: 'RiskScoreIncrease',
8
8
  // V2 Migration: Switch to per-entity for parallel processing
9
- type: 'per-entity',
9
+ type: 'per-entity',
10
10
  category: 'alerts',
11
11
  isHistorical: true,
12
-
12
+
13
13
  // V2 Migration: Define strict data requirements
14
14
  requires: {
15
- 'rankings': {
15
+ 'pi_rankings': {
16
16
  lookback: 1, // Fetches Today + Yesterday
17
17
  mandatory: true,
18
18
  // Optimization: Only fetch needed fields
19
- fields: ['user_id', 'rankings_data', 'date'],
19
+ fields: ['pi_id', 'rankings_data', 'date'],
20
20
  // Filter applied at data fetch level (replacing V1 userType logic)
21
21
  filter: { user_type: 'POPULAR_INVESTOR' }
22
22
  }
@@ -28,7 +28,7 @@ class RiskScoreIncrease extends Computation {
28
28
  firestore: {
29
29
  enabled: true,
30
30
  // Ensures V1 alert system can find the document
31
- path: 'alerts/{date}/RiskScoreIncrease/{entityId}',
31
+ path: 'alerts/{date}/RiskScoreIncrease/{entityId}',
32
32
  merge: true
33
33
  }
34
34
  },
@@ -44,7 +44,7 @@ class RiskScoreIncrease extends Computation {
44
44
  description: 'Alert when a Popular Investor\'s risk score increases',
45
45
  messageTemplate: '{piUsername}\'s risk score increased by {change} points (from {previous} to {current})',
46
46
  severity: 'high',
47
- configKey: 'increasedRisk',
47
+ configKey: 'increasedRisk',
48
48
  isDynamic: true,
49
49
  dynamicConfig: {
50
50
  thresholds: [
@@ -72,9 +72,9 @@ class RiskScoreIncrease extends Computation {
72
72
  }
73
73
  ],
74
74
  resultFields: {
75
- change: 'change',
76
- newValue: 'currentRisk',
77
- oldValue: 'previousRisk'
75
+ change: 'change',
76
+ newValue: 'currentRisk',
77
+ oldValue: 'previousRisk'
78
78
  }
79
79
  }
80
80
  }
@@ -87,15 +87,15 @@ class RiskScoreIncrease extends Computation {
87
87
  // 1. Data Access
88
88
  // In V2 with lookback: 1, this is an array of entries sorted by date
89
89
  const rankingHistory = data['rankings'] || [];
90
-
90
+
91
91
  // Find today's and yesterday's entries specifically by date string
92
92
  const todayEntry = rankingHistory.find(d => d.date === date);
93
-
93
+
94
94
  // Calculate yesterday's date string for lookup
95
95
  const yesterdayDate = new Date(date);
96
96
  yesterdayDate.setDate(yesterdayDate.getDate() - 1);
97
97
  const yesterdayStr = yesterdayDate.toISOString().split('T')[0];
98
-
98
+
99
99
  const yesterdayEntry = rankingHistory.find(d => d.date === yesterdayStr);
100
100
 
101
101
  // 2. Business Logic (Using Rules)
@@ -112,7 +112,7 @@ class RiskScoreIncrease extends Computation {
112
112
  // If today's risk data is missing, we cannot evaluate
113
113
  if (currentRisk === null || currentRisk === undefined) {
114
114
  this.log('WARN', `No current risk data for ${entityId}`);
115
- return;
115
+ return;
116
116
  }
117
117
 
118
118
  // 4. Comparison Logic
@@ -0,0 +1,138 @@
1
+ const { Computation } = require('../framework');
2
+
3
+ class SignedInUserMirrorHistory extends Computation {
4
+ static getConfig() {
5
+ return {
6
+ name: 'SignedInUserMirrorHistory',
7
+ type: 'per-entity',
8
+ category: 'signed_in_user',
9
+
10
+ // CRITICAL: Must be historical to accumulate the "Past" list over time
11
+ // without needing huge lookbacks.
12
+ isHistorical: true,
13
+
14
+ requires: {
15
+ 'portfolio_snapshots': {
16
+ lookback: 0,
17
+ mandatory: true,
18
+ fields: ['user_id', 'portfolio_data', 'date']
19
+ },
20
+ // Reduced from 90 to 30 to comply with DAG limits
21
+ 'trade_history_snapshots': {
22
+ lookback: 30,
23
+ mandatory: false,
24
+ fields: ['user_id', 'history_data']
25
+ },
26
+ 'pi_master_list': {
27
+ lookback: 1,
28
+ mandatory: false,
29
+ fields: ['cid', 'username']
30
+ }
31
+ },
32
+
33
+ storage: {
34
+ bigquery: true,
35
+ firestore: {
36
+ enabled: true,
37
+ path: 'users/{entityId}/mirror_history',
38
+ merge: true
39
+ }
40
+ }
41
+ };
42
+ }
43
+
44
+ async process(context) {
45
+ const { data, entityId, rules, previousResult } = context;
46
+
47
+ // 1. Setup & Username Map
48
+ const masterList = data['pi_master_list'] || [];
49
+ const usernameMap = new Map();
50
+ const toArray = (input) => Array.isArray(input) ? input : Object.values(input || {});
51
+
52
+ toArray(masterList).forEach(row => {
53
+ if (row.cid && row.username) usernameMap.set(String(row.cid), row.username);
54
+ });
55
+
56
+ // 2. Determine CURRENT Mirrors (From Today's Portfolio)
57
+ const currentMap = new Map();
58
+ const portfolioRow = data['portfolio_snapshots'];
59
+
60
+ if (portfolioRow) {
61
+ const pData = rules.portfolio.extractPortfolioData(portfolioRow);
62
+ const rawMirrors = rules.portfolio.extractMirrors(pData);
63
+
64
+ rawMirrors.forEach(m => {
65
+ const cid = String(m.ParentCID || m.MirrorID);
66
+ if (cid && cid !== '0') {
67
+ currentMap.set(cid, {
68
+ cid: Number(cid),
69
+ username: usernameMap.get(cid) || m.ParentUsername || 'Unknown',
70
+ invested: m.Invested || 0,
71
+ profit: m.NetProfit || 0,
72
+ status: 'active',
73
+ startedAt: m.InitDate || null
74
+ });
75
+ }
76
+ });
77
+ }
78
+
79
+ // 3. Determine PAST Mirrors (Merge Previous State + New Closures)
80
+ const pastMap = new Map();
81
+
82
+ // A. Load existing past mirrors from yesterday's result
83
+ if (previousResult && Array.isArray(previousResult.past)) {
84
+ previousResult.past.forEach(pm => pastMap.set(String(pm.cid), pm));
85
+ }
86
+
87
+ // B. Detect New Closures (Was in Previous Current, NOT in Today's Current)
88
+ if (previousResult && Array.isArray(previousResult.current)) {
89
+ previousResult.current.forEach(prevCurr => {
90
+ const cidStr = String(prevCurr.cid);
91
+ if (!currentMap.has(cidStr)) {
92
+ // This mirror was active yesterday but is gone today -> It closed.
93
+ pastMap.set(cidStr, {
94
+ ...prevCurr,
95
+ status: 'closed',
96
+ closedAt: context.date, // Mark closure date as today
97
+ invested: 0 // Reset invested for closed items
98
+ });
99
+ }
100
+ });
101
+ }
102
+
103
+ // C. Supplement with Trade History (Only look back 30 days for metadata)
104
+ // This catches any explicit "Stop Copy" events that might provide better metadata
105
+ const historyRows = data['trade_history_snapshots'] || [];
106
+ historyRows.forEach(row => {
107
+ const trades = rules.trades.extractTrades(row);
108
+ trades.forEach(trade => {
109
+ const parentCID = trade.ParentCID || trade.MirrorID || trade.CopyTraderID;
110
+ const cidStr = String(parentCID);
111
+
112
+ // If we found a past interaction that isn't currently active
113
+ if (parentCID && cidStr !== '0' && !currentMap.has(cidStr)) {
114
+ if (!pastMap.has(cidStr)) {
115
+ // Found a copy in history that wasn't in our previous state
116
+ // (e.g. from before we started tracking, or within the 30d window)
117
+ pastMap.set(cidStr, {
118
+ cid: Number(parentCID),
119
+ username: usernameMap.get(cidStr) || 'Unknown',
120
+ status: 'closed',
121
+ lastInteraction: rules.trades.getCloseDate(trade) || row.date
122
+ });
123
+ }
124
+ }
125
+ });
126
+ });
127
+
128
+ // 4. Final Output
129
+ this.setResult(entityId, {
130
+ current: Array.from(currentMap.values()),
131
+ past: Array.from(pastMap.values()),
132
+ totalUniqueCopied: currentMap.size + pastMap.size,
133
+ updatedAt: new Date().toISOString()
134
+ });
135
+ }
136
+ }
137
+
138
+ module.exports = SignedInUserMirrorHistory;
@@ -0,0 +1,106 @@
1
+ const { Computation } = require('../framework');
2
+
3
+ class SignedInUserPIProfileMetrics extends Computation {
4
+ static getConfig() {
5
+ return {
6
+ name: 'SignedInUserPIProfileMetrics',
7
+ type: 'per-entity',
8
+ category: 'signed_in_user',
9
+ isHistorical: true,
10
+ userType: 'SIGNED_IN_USER',
11
+
12
+ requires: {
13
+ // DRIVER TABLE
14
+ // lookback: 0 prevents "All history" fetch.
15
+ // filter: user_type ensures only signed-in users are fetched.
16
+ 'portfolio_snapshots': {
17
+ lookback: 0,
18
+ mandatory: true,
19
+ fields: ['user_id', 'user_type', 'portfolio_data', 'date'],
20
+ filter: { user_type: 'SIGNED_IN_USER' }
21
+ },
22
+ 'trade_history_snapshots': {
23
+ lookback: 0,
24
+ fields: ['user_id', 'user_type', 'history_data', 'date'],
25
+ filter: { user_type: 'SIGNED_IN_USER' }
26
+ },
27
+ 'ticker_mappings': { mandatory: false, fields: ['instrument_id', 'ticker'] },
28
+ 'sector_mappings': { mandatory: false, fields: ['symbol', 'sector'] }
29
+ },
30
+
31
+ storage: {
32
+ bigquery: true,
33
+ firestore: {
34
+ enabled: true,
35
+ path: 'users/{entityId}/pi_profile_metrics/{date}',
36
+ merge: true
37
+ }
38
+ }
39
+ };
40
+ }
41
+
42
+ async process(context) {
43
+ const { data, entityId, rules, dataFetcher } = context;
44
+ const portfolioRow = data['portfolio_snapshots'];
45
+
46
+ // Guard clause REMOVED to prove framework filtering works
47
+
48
+ // 2. PI STATUS CHECK (Intersection)
49
+ // Dynamically verify if this specific customer ID exists in the master list.
50
+ const piCheck = await dataFetcher.fetch({
51
+ table: 'pi_master_list',
52
+ filter: { cid: parseInt(entityId, 10) },
53
+ fields: ['cid'],
54
+ lookback: 0
55
+ });
56
+
57
+ if (!piCheck || piCheck.length === 0) {
58
+ return;
59
+ }
60
+
61
+ // 3. CORE LOGIC
62
+ const result = {
63
+ isPopularInvestor: true,
64
+ portfolioSummary: { totalValue: 0, profitPercent: 0, winRatio: 0 },
65
+ sectorExposure: {},
66
+ updatedAt: new Date().toISOString()
67
+ };
68
+
69
+ const pData = rules.portfolio.extractPortfolioData(portfolioRow);
70
+ const positions = rules.portfolio.extractPositions(pData);
71
+
72
+ result.portfolioSummary = {
73
+ totalValue: rules.portfolio.calculateTotalValue(positions),
74
+ profitPercent: rules.portfolio.calculateWeightedProfitPercent(positions),
75
+ winRatio: rules.portfolio.calculateWinRatio(positions)
76
+ };
77
+
78
+ const tickerMap = this._buildTickerMap(data['ticker_mappings']);
79
+ const sectorMap = this._buildSectorMap(data['sector_mappings']);
80
+ const instrumentSectorMap = {};
81
+ Object.keys(tickerMap).forEach(instId => {
82
+ const ticker = tickerMap[instId];
83
+ if (sectorMap[ticker]) instrumentSectorMap[instId] = sectorMap[ticker];
84
+ });
85
+
86
+ result.sectorExposure = rules.portfolio.calculateSectorExposure(positions, instrumentSectorMap);
87
+
88
+ this.setResult(entityId, result);
89
+ }
90
+
91
+ _buildTickerMap(rows) {
92
+ const map = {};
93
+ const arr = Array.isArray(rows) ? rows : Object.values(rows || {});
94
+ arr.forEach(r => { if (r.instrument_id) map[r.instrument_id] = r.ticker; });
95
+ return map;
96
+ }
97
+
98
+ _buildSectorMap(rows) {
99
+ const map = {};
100
+ const arr = Array.isArray(rows) ? rows : Object.values(rows || {});
101
+ arr.forEach(r => { if (r.symbol) map[r.symbol] = r.sector; });
102
+ return map;
103
+ }
104
+ }
105
+
106
+ module.exports = SignedInUserPIProfileMetrics;