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.
- package/functions/computation-system-v2/UserPortfolioMetrics.js +50 -0
- package/functions/computation-system-v2/computations/BehavioralAnomaly.js +559 -227
- package/functions/computation-system-v2/computations/GlobalAumPerAsset30D.js +103 -0
- package/functions/computation-system-v2/computations/NewSectorExposure.js +82 -35
- package/functions/computation-system-v2/computations/NewSocialPost.js +52 -24
- package/functions/computation-system-v2/computations/PIDailyAssetAUM.js +134 -0
- package/functions/computation-system-v2/computations/PiFeatureVectors.js +227 -0
- package/functions/computation-system-v2/computations/PiRecommender.js +359 -0
- package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +354 -641
- package/functions/computation-system-v2/computations/SignedInUserList.js +51 -0
- package/functions/computation-system-v2/computations/SignedInUserMirrorHistory.js +138 -0
- package/functions/computation-system-v2/computations/SignedInUserPIProfileMetrics.js +106 -0
- package/functions/computation-system-v2/computations/SignedInUserProfileMetrics.js +324 -0
- package/functions/computation-system-v2/config/bulltrackers.config.js +40 -126
- package/functions/computation-system-v2/core-api.js +17 -9
- package/functions/computation-system-v2/data_schema_reference.MD +108 -0
- package/functions/computation-system-v2/devtools/builder/builder.js +362 -0
- package/functions/computation-system-v2/devtools/builder/examples/user-metrics.yaml +26 -0
- package/functions/computation-system-v2/devtools/index.js +36 -0
- package/functions/computation-system-v2/devtools/shared/MockDataFactory.js +235 -0
- package/functions/computation-system-v2/devtools/shared/SchemaTemplates.js +475 -0
- package/functions/computation-system-v2/devtools/shared/SystemIntrospector.js +517 -0
- package/functions/computation-system-v2/devtools/shared/index.js +16 -0
- package/functions/computation-system-v2/devtools/simulation/DAGAnalyzer.js +243 -0
- package/functions/computation-system-v2/devtools/simulation/MockDataFetcher.js +306 -0
- package/functions/computation-system-v2/devtools/simulation/MockStorageManager.js +336 -0
- package/functions/computation-system-v2/devtools/simulation/SimulationEngine.js +525 -0
- package/functions/computation-system-v2/devtools/simulation/SimulationServer.js +581 -0
- package/functions/computation-system-v2/devtools/simulation/index.js +17 -0
- package/functions/computation-system-v2/devtools/simulation/simulate.js +324 -0
- package/functions/computation-system-v2/devtools/vscode-computation/package.json +90 -0
- package/functions/computation-system-v2/devtools/vscode-computation/snippets/computation.json +128 -0
- package/functions/computation-system-v2/devtools/vscode-computation/src/extension.ts +401 -0
- package/functions/computation-system-v2/devtools/vscode-computation/src/providers/codeActions.ts +152 -0
- package/functions/computation-system-v2/devtools/vscode-computation/src/providers/completions.ts +207 -0
- package/functions/computation-system-v2/devtools/vscode-computation/src/providers/diagnostics.ts +205 -0
- package/functions/computation-system-v2/devtools/vscode-computation/src/providers/hover.ts +205 -0
- package/functions/computation-system-v2/devtools/vscode-computation/tsconfig.json +22 -0
- package/functions/computation-system-v2/docs/HowToCreateComputations.MD +602 -0
- package/functions/computation-system-v2/framework/core/Manifest.js +9 -16
- package/functions/computation-system-v2/framework/core/RunAnalyzer.js +2 -1
- package/functions/computation-system-v2/framework/data/DataFetcher.js +330 -126
- package/functions/computation-system-v2/framework/data/MaterializedViewManager.js +84 -0
- package/functions/computation-system-v2/framework/data/QueryBuilder.js +38 -38
- package/functions/computation-system-v2/framework/execution/Orchestrator.js +226 -153
- package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +17 -19
- package/functions/computation-system-v2/framework/storage/StateRepository.js +32 -2
- package/functions/computation-system-v2/framework/storage/StorageManager.js +111 -83
- package/functions/computation-system-v2/framework/testing/ComputationTester.js +161 -66
- package/functions/computation-system-v2/handlers/dispatcher.js +57 -29
- package/functions/computation-system-v2/legacy/PiAssetRecommender.js.old +115 -0
- package/functions/computation-system-v2/legacy/PiSimilarityMatrix.js +104 -0
- package/functions/computation-system-v2/legacy/PiSimilarityVector.js +71 -0
- package/functions/computation-system-v2/scripts/debug_aggregation.js +25 -0
- package/functions/computation-system-v2/scripts/test-computation-dag.js +109 -0
- package/functions/computation-system-v2/scripts/test-invalidation-scenarios.js +234 -0
- package/functions/task-engine/helpers/data_storage_helpers.js +6 -6
- package/package.json +1 -1
- package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +0 -176
- package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +0 -294
- package/functions/computation-system-v2/computations/UserPortfolioSummary.js +0 -172
- package/functions/computation-system-v2/scripts/migrate-sectors.js +0 -73
- package/functions/computation-system-v2/test/analyze-results.js +0 -238
- package/functions/computation-system-v2/test/other/test-dependency-cascade.js +0 -150
- package/functions/computation-system-v2/test/other/test-dispatcher.js +0 -317
- package/functions/computation-system-v2/test/other/test-framework.js +0 -500
- package/functions/computation-system-v2/test/other/test-real-execution.js +0 -166
- package/functions/computation-system-v2/test/other/test-real-integration.js +0 -194
- package/functions/computation-system-v2/test/other/test-refactor-e2e.js +0 -131
- package/functions/computation-system-v2/test/other/test-results.json +0 -31
- package/functions/computation-system-v2/test/other/test-risk-metrics-computation.js +0 -329
- package/functions/computation-system-v2/test/other/test-scheduler.js +0 -204
- package/functions/computation-system-v2/test/other/test-storage.js +0 -449
- package/functions/computation-system-v2/test/run-pipeline-test.js +0 -554
- package/functions/computation-system-v2/test/test-full-pipeline.js +0 -227
- 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;
|