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,227 @@
1
+ /**
2
+ * @fileoverview PI Feature Vectors
3
+ *
4
+ * Global computation that builds normalized feature vectors for all Popular Investors.
5
+ * Used by PiRecommender to find balanced recommendations.
6
+ *
7
+ * Features computed per PI:
8
+ * - Sector exposure (normalized %)
9
+ * - Risk profile (volatility, risk score)
10
+ * - Performance metrics (gain %, win ratio)
11
+ * - Quality indicators (AUM tier, copiers)
12
+ */
13
+ const { Computation } = require('../framework');
14
+
15
+ class PiFeatureVectors extends Computation {
16
+ static getConfig() {
17
+ return {
18
+ name: 'PiFeatureVectors',
19
+ description: 'Computes feature vectors for all PIs for recommendation matching',
20
+ type: 'global',
21
+ category: 'popular_investor',
22
+ isHistorical: false,
23
+
24
+ requires: {
25
+ 'portfolio_snapshots': {
26
+ lookback: 0,
27
+ mandatory: true,
28
+ fields: ['user_id', 'portfolio_data', 'date'],
29
+ filter: { user_type: 'POPULAR_INVESTOR' }
30
+ },
31
+ 'pi_rankings': {
32
+ lookback: 1,
33
+ mandatory: true,
34
+ fields: ['pi_id', 'rankings_data', 'username', 'date']
35
+ },
36
+ 'pi_master_list': {
37
+ lookback: 1,
38
+ mandatory: false,
39
+ fields: ['cid', 'username', 'verified_date']
40
+ },
41
+ 'ticker_mappings': { mandatory: false },
42
+ 'sector_mappings': { mandatory: false }
43
+ },
44
+
45
+ storage: {
46
+ bigquery: true,
47
+ firestore: { enabled: false }
48
+ }
49
+ };
50
+ }
51
+
52
+ async process(context) {
53
+ const { data, rules, targetDate } = context;
54
+
55
+ // ===========================================================================
56
+ // SETUP: Helpers and reference data
57
+ // ===========================================================================
58
+ const toArray = (input) => {
59
+ if (!input) return [];
60
+ if (Array.isArray(input)) return input;
61
+ // Handle Map-like structure from DataFetcher
62
+ return Object.values(input).flat();
63
+ };
64
+
65
+ // Build sector lookup
66
+ const tickerMap = new Map();
67
+ toArray(data['ticker_mappings']).forEach(row => {
68
+ if (row.instrument_id && row.ticker) {
69
+ tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
70
+ }
71
+ });
72
+
73
+ const sectorMap = new Map();
74
+ toArray(data['sector_mappings']).forEach(row => {
75
+ if (row.symbol && row.sector) {
76
+ sectorMap.set(row.symbol.toUpperCase(), row.sector);
77
+ }
78
+ });
79
+
80
+ const resolveSector = (instrumentId) => {
81
+ const ticker = tickerMap.get(Number(instrumentId));
82
+ return ticker ? (sectorMap.get(ticker) || 'Other') : 'Other';
83
+ };
84
+
85
+ // All known sectors for consistent vector dimensions
86
+ const ALL_SECTORS = [
87
+ 'Technology', 'Healthcare', 'Financial Services', 'Consumer Cyclical',
88
+ 'Industrials', 'Energy', 'Utilities', 'Real Estate', 'Communication Services',
89
+ 'Consumer Defensive', 'Basic Materials', 'Crypto', 'Commodities', 'ETF', 'Other'
90
+ ];
91
+
92
+ // ===========================================================================
93
+ // GATHER: PI portfolio and rankings data
94
+ // ===========================================================================
95
+ const rankings = toArray(data['pi_rankings']);
96
+ const portfolios = data['portfolio_snapshots']; // Map { piId: [rows] }
97
+ const masterList = toArray(data['pi_master_list']);
98
+
99
+ // Build username lookup
100
+ const usernameMap = new Map();
101
+ rankings.forEach(r => {
102
+ const id = String(r.pi_id || r.CustomerId);
103
+ usernameMap.set(id, r.username || r.UserName);
104
+ });
105
+ masterList.forEach(m => {
106
+ if (m.cid && m.username) usernameMap.set(String(m.cid), m.username);
107
+ });
108
+
109
+ // ===========================================================================
110
+ // COMPUTE: Feature vectors for each PI
111
+ // ===========================================================================
112
+ const featureVectors = {};
113
+
114
+ // Process each PI's portfolio
115
+ const piIds = Object.keys(portfolios || {});
116
+
117
+ for (const piId of piIds) {
118
+ const rows = Array.isArray(portfolios[piId]) ? portfolios[piId] : [portfolios[piId]];
119
+ const latestRow = rows[rows.length - 1];
120
+ if (!latestRow) continue;
121
+
122
+ // Extract positions
123
+ const pData = rules.portfolio.extractPortfolioData(latestRow);
124
+ const positions = rules.portfolio.extractPositions(pData);
125
+
126
+ if (positions.length === 0) continue;
127
+
128
+ // Calculate sector exposure
129
+ const sectorWeights = {};
130
+ let totalValue = 0;
131
+
132
+ positions.forEach(pos => {
133
+ const instrumentId = rules.portfolio.getInstrumentId(pos);
134
+ const invested = rules.portfolio.getInvested(pos) || 0;
135
+ const sector = resolveSector(instrumentId);
136
+
137
+ sectorWeights[sector] = (sectorWeights[sector] || 0) + invested;
138
+ totalValue += invested;
139
+ });
140
+
141
+ // Normalize sector exposure to percentages
142
+ const sectorVector = {};
143
+ ALL_SECTORS.forEach(s => {
144
+ sectorVector[s] = totalValue > 0
145
+ ? Number(((sectorWeights[s] || 0) / totalValue).toFixed(4))
146
+ : 0;
147
+ });
148
+
149
+ // Get ranking data
150
+ if (piIds.indexOf(piId) === 0) {
151
+ const availableIds = rankings.map(r => r.pi_id || 'undefined').join(',');
152
+ console.log(`[DEBUG] Available IDs in rankings: ${availableIds}`);
153
+ console.log(`[DEBUG] First ranking row keys: ${Object.keys(rankings[0] || {}).join(',')}`);
154
+ if (rankings[0]) console.log(`[DEBUG] First ranking row pi_id: ${rankings[0].pi_id}`);
155
+ }
156
+ const rankRow = rankings.find(r => String(r.pi_id || r.CustomerId) === String(piId));
157
+
158
+ if (piIds.indexOf(piId) === 0) {
159
+ console.log(`[DEBUG] PiId: ${piId}`);
160
+ console.log(`[DEBUG] RankRow found: ${!!rankRow}`);
161
+ if (rankRow) {
162
+ console.log(`[DEBUG] RankRow keys: ${Object.keys(rankRow).join(',')}`);
163
+ console.log(`[DEBUG] RankingsData type: ${typeof rankRow.rankings_data}`);
164
+ if (typeof rankRow.rankings_data === 'object') {
165
+ console.log(`[DEBUG] RankingsData keys: ${Object.keys(rankRow.rankings_data).join(',')}`);
166
+ console.log(`[DEBUG] RankingsData.Gain: ${rankRow.rankings_data.Gain}`);
167
+ }
168
+ }
169
+ }
170
+
171
+ let riskScore = 5, gain = 0, copiers = 0, winRatio = 0, aumTier = 0;
172
+
173
+ if (rankRow) {
174
+ const rData = rules.rankings.extractRankingsData(rankRow);
175
+ if (rData) {
176
+ riskScore = rules.rankings.getRiskScore(rData) || 5;
177
+ gain = rules.rankings.getTotalGain(rData) || 0;
178
+ copiers = rules.rankings.getCopiers(rData) || 0;
179
+ winRatio = rules.rankings.getWinRatio(rData) || 0;
180
+ aumTier = rules.rankings.getAUMTier(rData) || 0;
181
+ }
182
+ }
183
+
184
+ // Calculate concentration (Herfindahl index) - higher = more concentrated
185
+ const weights = Object.values(sectorWeights).filter(w => w > 0);
186
+ const hhi = totalValue > 0
187
+ ? weights.reduce((sum, w) => sum + Math.pow(w / totalValue, 2), 0)
188
+ : 1;
189
+
190
+ // Assemble feature vector
191
+ featureVectors[piId] = {
192
+ piId: String(piId),
193
+ username: usernameMap.get(String(piId)) || 'Unknown',
194
+
195
+ // Sector exposure (normalized)
196
+ sectors: sectorVector,
197
+
198
+ // Risk profile
199
+ riskScore: riskScore,
200
+ concentration: Number(hhi.toFixed(4)), // 0-1, higher = less diversified
201
+
202
+ // Performance
203
+ gain: gain,
204
+ winRatio: winRatio,
205
+
206
+ // Quality
207
+ copiers: copiers,
208
+ aumTier: aumTier,
209
+ positionCount: positions.length,
210
+
211
+ // Metadata
212
+ totalValue: Number(totalValue.toFixed(2)),
213
+ computedAt: targetDate
214
+ };
215
+ }
216
+
217
+ // Store all vectors as a single global result
218
+ this.setResult('_global', {
219
+ vectors: featureVectors,
220
+ piCount: Object.keys(featureVectors).length,
221
+ sectorDimensions: ALL_SECTORS,
222
+ computedAt: targetDate
223
+ });
224
+ }
225
+ }
226
+
227
+ module.exports = PiFeatureVectors;
@@ -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;