bulltrackers-module 1.0.769 → 1.0.771
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.
- package/functions/computation-system-v2/computations/GlobalAumPerAsset30D.js +11 -13
- package/functions/computation-system-v2/computations/PIDailyAssetAUM.js +17 -19
- package/functions/computation-system-v2/computations/PiFeatureVectors.js +44 -90
- package/functions/computation-system-v2/computations/PiRecommender.js +155 -226
- package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +20 -20
- package/functions/computation-system-v2/computations/RiskScoreIncrease.js +13 -13
- package/functions/computation-system-v2/computations/SectorCorrelations.js +228 -0
- package/functions/computation-system-v2/computations/SignedInUserMirrorHistory.js +15 -32
- package/functions/computation-system-v2/computations/SignedInUserProfileMetrics.js +31 -31
- package/functions/computation-system-v2/framework/core/RunAnalyzer.js +3 -2
- package/functions/computation-system-v2/framework/data/DataFetcher.js +1 -1
- package/functions/computation-system-v2/framework/storage/StateRepository.js +13 -11
- package/functions/computation-system-v2/handlers/scheduler.js +172 -203
- package/package.json +1 -1
- package/functions/computation-system-v2/computations/SignedInUserList.js +0 -51
- package/functions/computation-system-v2/docs/Agents.MD +0 -964
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview PI Recommender (
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
2
|
+
* @fileoverview PI Recommender (v3 - Recursive Correlation)
|
|
3
|
+
* * Recommends Popular Investors by:
|
|
4
|
+
* 1. Recursively unwrapping the user's copied PIs to find True Net Exposure.
|
|
5
|
+
* 2. Calculating mathematical correlation between User and Candidate using Asset/Sector matrices.
|
|
6
|
+
* 3. Favoring "Uncorrelated" (near 0) over "Inverse" (near -1) or "Correlated" (near 1).
|
|
11
7
|
*/
|
|
12
8
|
const { Computation } = require('../framework');
|
|
13
9
|
|
|
@@ -15,31 +11,28 @@ class PiRecommender extends Computation {
|
|
|
15
11
|
static getConfig() {
|
|
16
12
|
return {
|
|
17
13
|
name: 'PiRecommender',
|
|
18
|
-
description: 'Recommends PIs
|
|
14
|
+
description: 'Recommends PIs based on recursive portfolio variance reduction',
|
|
19
15
|
type: 'per-entity',
|
|
20
16
|
category: 'signed_in_user',
|
|
21
17
|
isHistorical: false,
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
dependencies: ['PiFeatureVectors'],
|
|
19
|
+
dependencies: ['pifeaturevectors', 'sectorcorrelations'],
|
|
25
20
|
|
|
26
21
|
requires: {
|
|
27
22
|
'portfolio_snapshots': {
|
|
28
|
-
lookback:
|
|
23
|
+
lookback: 7,
|
|
29
24
|
mandatory: true,
|
|
30
25
|
fields: ['user_id', 'portfolio_data', 'date'],
|
|
31
26
|
filter: { user_type: 'SIGNED_IN_USER' }
|
|
32
27
|
},
|
|
33
|
-
'ticker_mappings': { mandatory: false },
|
|
34
|
-
'sector_mappings': { mandatory: false }
|
|
28
|
+
'ticker_mappings': { mandatory: false, fields: ['instrument_id', 'ticker'] },
|
|
29
|
+
'sector_mappings': { mandatory: false, fields: ['symbol', 'sector'] }
|
|
35
30
|
},
|
|
36
31
|
|
|
37
32
|
storage: {
|
|
38
33
|
bigquery: true,
|
|
39
34
|
firestore: {
|
|
40
|
-
enabled:
|
|
41
|
-
path: 'users/{entityId}/recommendations',
|
|
42
|
-
merge: true
|
|
35
|
+
enabled: false
|
|
43
36
|
}
|
|
44
37
|
}
|
|
45
38
|
};
|
|
@@ -48,45 +41,40 @@ class PiRecommender extends Computation {
|
|
|
48
41
|
async process(context) {
|
|
49
42
|
const { data, entityId, rules, getDependency, targetDate } = context;
|
|
50
43
|
|
|
44
|
+
const portfolioRows = data['portfolio_snapshots'];
|
|
45
|
+
if (!portfolioRows || !Array.isArray(portfolioRows) || portfolioRows.length === 0) {
|
|
46
|
+
this.setResult(entityId, { recommendations: [], reason: 'No portfolio data' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
51
50
|
// ===========================================================================
|
|
52
|
-
//
|
|
51
|
+
// 1. LOAD GLOBAL DEPENDENCIES
|
|
53
52
|
// ===========================================================================
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
diversification: 0.40, // How different they are (highest weight!)
|
|
57
|
-
quality: 0.30 // Performance and popularity
|
|
58
|
-
};
|
|
53
|
+
const piFeatureData = getDependency('pifeaturevectors', '_global');
|
|
54
|
+
const correlationData = getDependency('sectorcorrelations', '_global');
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
excludeAlreadyCopied: true
|
|
65
|
-
};
|
|
56
|
+
if (!piFeatureData || !correlationData) {
|
|
57
|
+
this.setResult(entityId, { error: 'Missing global dependencies' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
66
60
|
|
|
67
|
-
const
|
|
61
|
+
const piVectors = piFeatureData.vectors;
|
|
62
|
+
const sectorCorrelations = correlationData.correlationMatrix;
|
|
63
|
+
const sectorList = correlationData.sectors;
|
|
68
64
|
|
|
69
65
|
// ===========================================================================
|
|
70
|
-
// SETUP
|
|
66
|
+
// 2. SETUP MAPPINGS
|
|
71
67
|
// ===========================================================================
|
|
72
|
-
const toArray = (input) =>
|
|
73
|
-
if (!input) return [];
|
|
74
|
-
if (Array.isArray(input)) return input;
|
|
75
|
-
return Object.values(input).flat();
|
|
76
|
-
};
|
|
68
|
+
const toArray = (input) => Array.isArray(input) ? input : Object.values(input).flat();
|
|
77
69
|
|
|
78
70
|
const tickerMap = new Map();
|
|
79
71
|
toArray(data['ticker_mappings']).forEach(row => {
|
|
80
|
-
if (row.instrument_id && row.ticker)
|
|
81
|
-
tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
|
|
82
|
-
}
|
|
72
|
+
if (row.instrument_id && row.ticker) tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
|
|
83
73
|
});
|
|
84
74
|
|
|
85
75
|
const sectorMap = new Map();
|
|
86
76
|
toArray(data['sector_mappings']).forEach(row => {
|
|
87
|
-
if (row.symbol && row.sector)
|
|
88
|
-
sectorMap.set(row.symbol.toUpperCase(), row.sector);
|
|
89
|
-
}
|
|
77
|
+
if (row.symbol && row.sector) sectorMap.set(row.symbol.toUpperCase(), row.sector);
|
|
90
78
|
});
|
|
91
79
|
|
|
92
80
|
const resolveSector = (instrumentId) => {
|
|
@@ -95,265 +83,206 @@ class PiRecommender extends Computation {
|
|
|
95
83
|
};
|
|
96
84
|
|
|
97
85
|
// ===========================================================================
|
|
98
|
-
//
|
|
86
|
+
// 3. BUILD USER'S TRUE RECURSIVE PROFILE
|
|
99
87
|
// ===========================================================================
|
|
100
|
-
const
|
|
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
|
-
|
|
88
|
+
const latestRow = portfolioRows[portfolioRows.length - 1];
|
|
110
89
|
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
90
|
|
|
119
|
-
//
|
|
120
|
-
const
|
|
121
|
-
|
|
91
|
+
// Initialize User Vector (Units: % of Portfolio Value)
|
|
92
|
+
const userNetVector = {};
|
|
93
|
+
sectorList.forEach(s => userNetVector[s] = 0);
|
|
122
94
|
|
|
95
|
+
// 3a. Add Direct Positions (Signed Value)
|
|
96
|
+
const positions = rules.portfolio.extractPositions(pData);
|
|
123
97
|
positions.forEach(pos => {
|
|
124
98
|
const instrumentId = rules.portfolio.getInstrumentId(pos);
|
|
125
|
-
const
|
|
99
|
+
const value = rules.portfolio.getValue(pos) || 0; // Already % (e.g., 5.0 for 5%)
|
|
100
|
+
const isShort = rules.portfolio.isShort(pos);
|
|
126
101
|
const sector = resolveSector(instrumentId);
|
|
127
102
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// Include mirror exposures
|
|
133
|
-
mirrors.forEach(m => {
|
|
134
|
-
userTotalValue += (m.Invested || m.Amount || 0);
|
|
103
|
+
const signedValue = isShort ? -value : value;
|
|
104
|
+
if (userNetVector[sector] !== undefined) {
|
|
105
|
+
userNetVector[sector] += signedValue;
|
|
106
|
+
}
|
|
135
107
|
});
|
|
136
108
|
|
|
137
|
-
//
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
109
|
+
// 3b. Add Recursive Copy Exposure
|
|
110
|
+
const mirrors = rules.portfolio.extractMirrors(pData);
|
|
111
|
+
const copiedPiIds = new Set();
|
|
112
|
+
|
|
113
|
+
mirrors.forEach(mirror => {
|
|
114
|
+
const parentId = String(mirror.ParentCID || mirror.CID);
|
|
115
|
+
copiedPiIds.add(parentId);
|
|
116
|
+
|
|
117
|
+
// investedInCopy is % of user's portfolio (e.g., 20.0 for 20%)
|
|
118
|
+
const investedInCopy = mirror.Value || 0;
|
|
119
|
+
|
|
120
|
+
// Lookup Parent Vector (Weights are % of Parent's portfolio)
|
|
121
|
+
const parentStats = piVectors[parentId];
|
|
122
|
+
if (parentStats && parentStats.sectors) {
|
|
123
|
+
Object.entries(parentStats.sectors).forEach(([sec, weight]) => {
|
|
124
|
+
if (userNetVector[sec] !== undefined) {
|
|
125
|
+
// Formula: Parent_Sector_% * (User_Alloc_% / 100) = Contributed_%
|
|
126
|
+
// Example: 50.0% * (20.0% / 100) = 10.0%
|
|
127
|
+
userNetVector[sec] += (weight * (investedInCopy / 100));
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
143
131
|
});
|
|
144
132
|
|
|
145
|
-
// Infer user preferences from copied PIs
|
|
146
|
-
const userPreferredRiskRange = this._inferRiskPreference(mirrors, rules);
|
|
147
|
-
|
|
148
133
|
// ===========================================================================
|
|
149
|
-
//
|
|
134
|
+
// 4. SCORE CANDIDATES
|
|
150
135
|
// ===========================================================================
|
|
151
|
-
const
|
|
152
|
-
if (!piFeatureData || !piFeatureData.vectors) {
|
|
153
|
-
this.setResult(entityId, { recommendations: [], reason: 'No PI features available' });
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const piVectors = piFeatureData.vectors;
|
|
136
|
+
const candidates = [];
|
|
158
137
|
const allPiIds = Object.keys(piVectors);
|
|
159
138
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
139
|
+
const WEIGHTS = {
|
|
140
|
+
diversification: 0.50, // Correlation reduction
|
|
141
|
+
quality: 0.30, // Risk/Reward
|
|
142
|
+
interest: 0.20 // Slight bias to things they might understand
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const THRESHOLDS = {
|
|
146
|
+
minCopiers: 10,
|
|
147
|
+
maxRiskScore: 8,
|
|
148
|
+
minGain: -10,
|
|
149
|
+
excludeAlreadyCopied: true
|
|
150
|
+
};
|
|
164
151
|
|
|
165
152
|
for (const piId of allPiIds) {
|
|
166
|
-
|
|
167
|
-
if (THRESHOLDS.excludeAlreadyCopied && copiedPiIds.has(piId)) {
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
153
|
+
if (THRESHOLDS.excludeAlreadyCopied && copiedPiIds.has(piId)) continue;
|
|
170
154
|
|
|
171
155
|
const piFv = piVectors[piId];
|
|
172
|
-
|
|
173
|
-
// Apply quality thresholds
|
|
174
156
|
if (piFv.copiers < THRESHOLDS.minCopiers) continue;
|
|
175
157
|
if (piFv.riskScore > THRESHOLDS.maxRiskScore) continue;
|
|
176
158
|
if (piFv.gain < THRESHOLDS.minGain) continue;
|
|
177
159
|
|
|
178
|
-
//
|
|
179
|
-
const
|
|
180
|
-
|
|
160
|
+
// Metric: Portfolio Correlation
|
|
161
|
+
const correlation = this._calculatePortfolioCorrelation(
|
|
162
|
+
userNetVector,
|
|
163
|
+
piFv.sectors,
|
|
164
|
+
sectorCorrelations,
|
|
165
|
+
sectorList
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Diversification Score:
|
|
169
|
+
// 0.0 = Uncorrelated (Best) -> Score 1.0
|
|
170
|
+
// 1.0 = Highly Correlated (Worst) -> Score 0.0
|
|
171
|
+
// -1.0 = Inverse -> Score 0.0 (We prioritize uncorrelation over hedging here)
|
|
172
|
+
const diversificationScore = 1 - Math.abs(correlation);
|
|
173
|
+
|
|
174
|
+
// Interest Score (Overlap)
|
|
175
|
+
const interestScore = this._calcInterestScore(userNetVector, piFv.sectors);
|
|
176
|
+
|
|
177
|
+
// Quality Score
|
|
181
178
|
const qualityScore = this._calcQualityScore(piFv);
|
|
182
179
|
|
|
183
180
|
const totalScore =
|
|
184
|
-
(interestScore * WEIGHTS.interest) +
|
|
185
181
|
(diversificationScore * WEIGHTS.diversification) +
|
|
186
|
-
(qualityScore * WEIGHTS.quality)
|
|
182
|
+
(qualityScore * WEIGHTS.quality) +
|
|
183
|
+
(interestScore * WEIGHTS.interest);
|
|
187
184
|
|
|
188
|
-
|
|
185
|
+
candidates.push({
|
|
189
186
|
piId: piFv.piId,
|
|
190
187
|
username: piFv.username,
|
|
191
188
|
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
189
|
metrics: {
|
|
190
|
+
correlation: Number(correlation.toFixed(3)),
|
|
199
191
|
riskScore: piFv.riskScore,
|
|
200
192
|
gain: piFv.gain,
|
|
201
|
-
copiers: piFv.copiers,
|
|
202
|
-
winRatio: piFv.winRatio,
|
|
203
193
|
topSectors: this._getTopSectors(piFv.sectors, 3)
|
|
204
194
|
},
|
|
205
|
-
reason: this._generateReason(
|
|
195
|
+
reason: this._generateReason(correlation, piFv)
|
|
206
196
|
});
|
|
207
197
|
}
|
|
208
198
|
|
|
209
199
|
// ===========================================================================
|
|
210
|
-
//
|
|
200
|
+
// 5. SORT AND RETURN
|
|
211
201
|
// ===========================================================================
|
|
212
|
-
const recommendations =
|
|
202
|
+
const recommendations = candidates
|
|
213
203
|
.sort((a, b) => b.score - a.score)
|
|
214
|
-
.slice(0,
|
|
204
|
+
.slice(0, 10);
|
|
215
205
|
|
|
216
206
|
this.setResult(entityId, {
|
|
217
207
|
recommendations,
|
|
218
208
|
userProfile: {
|
|
219
|
-
topSectors: this._getTopSectors(
|
|
220
|
-
|
|
221
|
-
currentCopies: copiedPiIds.size
|
|
209
|
+
topSectors: this._getTopSectors(userNetVector, 5),
|
|
210
|
+
totalExposure: Object.values(userNetVector).reduce((a, b) => a + b, 0)
|
|
222
211
|
},
|
|
223
212
|
metadata: {
|
|
224
|
-
candidatesScored:
|
|
225
|
-
totalPIs: allPiIds.length,
|
|
213
|
+
candidatesScored: candidates.length,
|
|
226
214
|
computedAt: targetDate
|
|
227
215
|
}
|
|
228
216
|
});
|
|
229
217
|
}
|
|
230
218
|
|
|
231
219
|
// ===========================================================================
|
|
232
|
-
//
|
|
220
|
+
// MATH HELPERS
|
|
233
221
|
// ===========================================================================
|
|
234
222
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
});
|
|
223
|
+
_calculatePortfolioCorrelation(vecA, vecB, corrMatrix, sectors) {
|
|
224
|
+
// Portfolio Variance Proxy: wT * C * w
|
|
225
|
+
// Uses Correlation Matrix as the Kernel.
|
|
246
226
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
: 0.5; // Neutral if no preference detected
|
|
227
|
+
let varA = 0;
|
|
228
|
+
let varB = 0;
|
|
229
|
+
let covAB = 0;
|
|
251
230
|
|
|
252
|
-
|
|
253
|
-
|
|
231
|
+
for (let i = 0; i < sectors.length; i++) {
|
|
232
|
+
for (let j = 0; j < sectors.length; j++) {
|
|
233
|
+
const s1 = sectors[i];
|
|
234
|
+
const s2 = sectors[j];
|
|
235
|
+
const corr = corrMatrix[s1]?.[s2] || 0;
|
|
254
236
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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;
|
|
237
|
+
const wa_i = vecA[s1] || 0;
|
|
238
|
+
const wa_j = vecA[s2] || 0;
|
|
239
|
+
const wb_i = vecB[s1] || 0;
|
|
240
|
+
const wb_j = vecB[s2] || 0;
|
|
281
241
|
|
|
282
|
-
|
|
242
|
+
varA += wa_i * wa_j * corr;
|
|
243
|
+
varB += wb_i * wb_j * corr;
|
|
244
|
+
covAB += wa_i * wb_j * corr;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
283
247
|
|
|
284
|
-
|
|
285
|
-
|
|
248
|
+
if (varA <= 0 || varB <= 0) return 0;
|
|
249
|
+
return covAB / Math.sqrt(varA * varB);
|
|
250
|
+
}
|
|
286
251
|
|
|
287
|
-
|
|
252
|
+
_calcInterestScore(userVec, piVec) {
|
|
253
|
+
// Dot product of positive weights only (simple overlap)
|
|
254
|
+
let score = 0;
|
|
255
|
+
Object.keys(userVec).forEach(k => {
|
|
256
|
+
// Check if both have exposure in the same direction (Long/Long or Short/Short)
|
|
257
|
+
if ((userVec[k] > 0 && piVec[k] > 0) || (userVec[k] < 0 && piVec[k] < 0)) {
|
|
258
|
+
score += Math.abs(userVec[k] * piVec[k]);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
// Normalize score roughly to 0-1 range (assuming total portfolio ~ 100%)
|
|
262
|
+
return Math.min(1, score / 1000);
|
|
288
263
|
}
|
|
289
264
|
|
|
290
|
-
/**
|
|
291
|
-
* Quality score: How good is the PI objectively?
|
|
292
|
-
* Combines performance, popularity, and track record
|
|
293
|
-
*/
|
|
294
265
|
_calcQualityScore(piFv) {
|
|
295
|
-
// Normalize gain to 0-1 scale (-50% to +100% typical range)
|
|
296
266
|
const gainNorm = Math.max(0, Math.min(1, (piFv.gain + 50) / 150));
|
|
297
|
-
|
|
298
|
-
// Win ratio already 0-100, normalize
|
|
299
267
|
const winRatioNorm = piFv.winRatio / 100;
|
|
300
|
-
|
|
301
|
-
// Copiers (log scale, cap at ~10k)
|
|
302
268
|
const copiersNorm = Math.min(1, Math.log10(Math.max(1, piFv.copiers)) / 4);
|
|
303
|
-
|
|
304
|
-
// Risk-adjusted (lower risk = bonus)
|
|
305
269
|
const riskBonus = (10 - piFv.riskScore) / 10 * 0.2;
|
|
306
|
-
|
|
307
270
|
return (gainNorm * 0.35) + (winRatioNorm * 0.25) + (copiersNorm * 0.2) + riskBonus;
|
|
308
271
|
}
|
|
309
272
|
|
|
310
|
-
|
|
311
|
-
|
|
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])
|
|
273
|
+
_getTopSectors(vec, n) {
|
|
274
|
+
return Object.entries(vec)
|
|
275
|
+
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
|
338
276
|
.slice(0, n)
|
|
339
|
-
.map(([
|
|
340
|
-
sector,
|
|
341
|
-
weight: Number((weight * 100).toFixed(1))
|
|
342
|
-
}));
|
|
277
|
+
.map(([s, w]) => ({ sector: s, weight: Number(w.toFixed(1)) }));
|
|
343
278
|
}
|
|
344
279
|
|
|
345
|
-
_generateReason(
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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`;
|
|
280
|
+
_generateReason(correlation, piFv) {
|
|
281
|
+
if (Math.abs(correlation) < 0.1) return `Mathematically uncorrelated to your current portfolio`;
|
|
282
|
+
if (correlation < -0.3) return `Hedges your portfolio against market downturns`;
|
|
283
|
+
if (piFv.gain > 30) return `Strong performer with uncorrelated returns`;
|
|
284
|
+
return `Diversifies your holdings in ${this._getTopSectors(piFv.sectors, 1)[0].sector}`;
|
|
356
285
|
}
|
|
357
286
|
}
|
|
358
287
|
|
|
359
|
-
module.exports = PiRecommender;
|
|
288
|
+
module.exports = PiRecommender;
|
|
@@ -14,7 +14,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
14
14
|
type: 'per-entity',
|
|
15
15
|
category: 'popular_investor',
|
|
16
16
|
isHistorical: true,
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
requires: {
|
|
19
19
|
// --- Core Data ---
|
|
20
20
|
'portfolio_snapshots': {
|
|
@@ -32,7 +32,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
32
32
|
mandatory: false,
|
|
33
33
|
fields: ['user_id', 'posts_data', 'date']
|
|
34
34
|
},
|
|
35
|
-
|
|
35
|
+
|
|
36
36
|
// --- Reference ---
|
|
37
37
|
'pi_master_list': {
|
|
38
38
|
lookback: 1,
|
|
@@ -76,7 +76,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
76
76
|
bigquery: true,
|
|
77
77
|
firestore: {
|
|
78
78
|
enabled: true,
|
|
79
|
-
path: '
|
|
79
|
+
path: 'PiProfiles/{entityId}/metrics/{date}',
|
|
80
80
|
merge: true
|
|
81
81
|
}
|
|
82
82
|
},
|
|
@@ -91,7 +91,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
91
91
|
// ==========================================================================================
|
|
92
92
|
// 0. PREPARATION & HELPER FUNCTIONS
|
|
93
93
|
// ==========================================================================================
|
|
94
|
-
|
|
94
|
+
|
|
95
95
|
const toDateStr = (d) => {
|
|
96
96
|
if (!d) return "";
|
|
97
97
|
if (d.value) return d.value;
|
|
@@ -137,7 +137,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
137
137
|
return dA.localeCompare(dB);
|
|
138
138
|
});
|
|
139
139
|
};
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
// Mappings (Global, not per-entity)
|
|
142
142
|
const tickerMap = new Map();
|
|
143
143
|
toArray(data['ticker_mappings']).forEach(row => {
|
|
@@ -178,7 +178,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
178
178
|
// ==========================================================================================
|
|
179
179
|
// 1. DATA RETRIEVAL (Using Fixed 'getEntityRows')
|
|
180
180
|
// ==========================================================================================
|
|
181
|
-
|
|
181
|
+
|
|
182
182
|
// These tables are keyed by 'user_id' (same as entityId) or 'pi_id' (same value)
|
|
183
183
|
const portfolios = sortAsc(getEntityRows(data['portfolio_snapshots']));
|
|
184
184
|
const historyData = sortAsc(getEntityRows(data['trade_history_snapshots']));
|
|
@@ -188,7 +188,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
188
188
|
const pageViews = sortAsc(getEntityRows(data['pi_page_views']));
|
|
189
189
|
const watchlists = sortAsc(getEntityRows(data['watchlist_membership']));
|
|
190
190
|
const alerts = sortAsc(getEntityRows(data['pi_alert_history']));
|
|
191
|
-
const masterList = getEntityRows(data['pi_master_list']);
|
|
191
|
+
const masterList = getEntityRows(data['pi_master_list']);
|
|
192
192
|
|
|
193
193
|
const currentPortfolio = portfolios.length > 0 ? portfolios[portfolios.length - 1] : null;
|
|
194
194
|
const currentRanking = rankings.length > 0 ? rankings[rankings.length - 1] : null;
|
|
@@ -234,10 +234,10 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
234
234
|
trades.forEach(trade => {
|
|
235
235
|
const closeDate = rules.trades.getCloseDate(trade);
|
|
236
236
|
if (!closeDate) return;
|
|
237
|
-
|
|
237
|
+
|
|
238
238
|
const dKey = closeDate.toISOString().split('T')[0];
|
|
239
239
|
const profit = rules.trades.getNetProfit(trade);
|
|
240
|
-
|
|
240
|
+
|
|
241
241
|
const entry = profitableMap.get(dKey) || { date: dKey, profitableCount: 0, totalCount: 0 };
|
|
242
242
|
entry.totalCount++;
|
|
243
243
|
if (profit > 0) entry.profitableCount++;
|
|
@@ -272,7 +272,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
272
272
|
if (currentPortfolio) {
|
|
273
273
|
const pData = rules.portfolio.extractPortfolioData(currentPortfolio);
|
|
274
274
|
const positions = rules.portfolio.extractPositions(pData);
|
|
275
|
-
|
|
275
|
+
|
|
276
276
|
let totalInvested = 0, totalProfit = 0;
|
|
277
277
|
const secMap = {};
|
|
278
278
|
const assetMap = {};
|
|
@@ -280,12 +280,12 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
280
280
|
|
|
281
281
|
positions.forEach(pos => {
|
|
282
282
|
const id = rules.portfolio.getInstrumentId(pos);
|
|
283
|
-
const invested = rules.portfolio.getInvested(pos);
|
|
283
|
+
const invested = rules.portfolio.getInvested(pos);
|
|
284
284
|
const netProfit = rules.portfolio.getNetProfit(pos);
|
|
285
|
-
|
|
285
|
+
|
|
286
286
|
totalInvested += invested;
|
|
287
287
|
totalProfit += (invested * (netProfit / 100));
|
|
288
|
-
|
|
288
|
+
|
|
289
289
|
const ticker = resolveTicker(id);
|
|
290
290
|
const sector = resolveSector(id);
|
|
291
291
|
|
|
@@ -304,7 +304,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
304
304
|
};
|
|
305
305
|
|
|
306
306
|
Object.entries(assetMap)
|
|
307
|
-
.sort(([,a], [,b]) => b - a)
|
|
307
|
+
.sort(([, a], [, b]) => b - a)
|
|
308
308
|
.slice(0, 10)
|
|
309
309
|
.forEach(([k, v]) => result.assetExposure.data[k] = Number(v.toFixed(2)));
|
|
310
310
|
|
|
@@ -331,10 +331,10 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
331
331
|
const pData = rules.portfolio.extractPortfolioData(entry);
|
|
332
332
|
const positions = rules.portfolio.extractPositions(pData);
|
|
333
333
|
const dateStr = toDateStr(entry.date);
|
|
334
|
-
|
|
334
|
+
|
|
335
335
|
const dailySec = {};
|
|
336
336
|
const dailyAsset = {};
|
|
337
|
-
|
|
337
|
+
|
|
338
338
|
positions.forEach(pos => {
|
|
339
339
|
const id = rules.portfolio.getInstrumentId(pos);
|
|
340
340
|
const invested = rules.portfolio.getInvested(pos);
|
|
@@ -351,8 +351,8 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
351
351
|
// ==========================================================================================
|
|
352
352
|
// 7. SOCIAL, RATINGS, PAGEVIEWS, WATCHLIST
|
|
353
353
|
// ==========================================================================================
|
|
354
|
-
const sevenDaysAgo = new Date(new Date(date).setDate(new Date(date).getDate() - 7)).toISOString().slice(0,10);
|
|
355
|
-
|
|
354
|
+
const sevenDaysAgo = new Date(new Date(date).setDate(new Date(date).getDate() - 7)).toISOString().slice(0, 10);
|
|
355
|
+
|
|
356
356
|
socialData.filter(d => toDateStr(d.date) >= sevenDaysAgo).forEach(day => {
|
|
357
357
|
const posts = rules.social.extractPosts(day);
|
|
358
358
|
let likes = 0, comments = 0;
|
|
@@ -379,7 +379,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
379
379
|
const d = toDateStr(p.date);
|
|
380
380
|
if (d) {
|
|
381
381
|
result.pageViewsData.viewsOverTime.data.push({ date: d, views });
|
|
382
|
-
result.pageViewsData.totalViews += views;
|
|
382
|
+
result.pageViewsData.totalViews += views;
|
|
383
383
|
result.pageViewsData.uniqueViewers = Number(p.unique_viewers || 0);
|
|
384
384
|
}
|
|
385
385
|
});
|
|
@@ -403,7 +403,7 @@ class PopularInvestorProfileMetrics extends Computation {
|
|
|
403
403
|
const d = toDateStr(row.date);
|
|
404
404
|
const type = row.alert_type;
|
|
405
405
|
const count = Number(row.trigger_count || 1);
|
|
406
|
-
|
|
406
|
+
|
|
407
407
|
if (type && type.toLowerCase().includes('test')) return;
|
|
408
408
|
|
|
409
409
|
result.alertMetrics.totalLast7Days += count;
|