bulltrackers-module 1.0.768 → 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 +557 -337
- package/functions/computation-system-v2/computations/GlobalAumPerAsset30D.js +103 -0
- 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/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 +30 -128
- 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/data/DataFetcher.js +250 -184
- 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 +215 -129
- 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 +105 -67
- package/functions/computation-system-v2/framework/testing/ComputationTester.js +12 -6
- 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-invalidation-scenarios.js +234 -0
- 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;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { Computation } = require('../framework');
|
|
2
|
+
|
|
3
|
+
class DebugSignedInUsers extends Computation {
|
|
4
|
+
static getConfig() {
|
|
5
|
+
return {
|
|
6
|
+
name: 'DebugSignedInUsers',
|
|
7
|
+
type: 'per-entity',
|
|
8
|
+
category: 'debug',
|
|
9
|
+
|
|
10
|
+
// CRITICAL: Tells the Orchestrator which partition to use
|
|
11
|
+
userType: 'SIGNED_IN_USER',
|
|
12
|
+
|
|
13
|
+
requires: {
|
|
14
|
+
// DRIVER TABLE: This MUST be first.
|
|
15
|
+
// The DAG will only create tasks for Entity IDs found in this filtered query.
|
|
16
|
+
'portfolio_snapshots': {
|
|
17
|
+
lookback: 0, // Just need today's existence
|
|
18
|
+
mandatory: true,
|
|
19
|
+
fields: ['user_id', 'date'],
|
|
20
|
+
filter: { user_type: 'SIGNED_IN_USER' }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
storage: {
|
|
25
|
+
// Just log to console or a debug table for now
|
|
26
|
+
bigquery: false,
|
|
27
|
+
firestore: { enabled: false }
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async process(context) {
|
|
33
|
+
const { entityId, data } = context;
|
|
34
|
+
|
|
35
|
+
// If the Orchestrator works correctly, we should ONLY see Signed-In Users here.
|
|
36
|
+
// We verify by returning their ID.
|
|
37
|
+
|
|
38
|
+
const row = data['portfolio_snapshots'];
|
|
39
|
+
|
|
40
|
+
// Sanity Check: If row exists, we are good.
|
|
41
|
+
if (row) {
|
|
42
|
+
this.setResult(entityId, {
|
|
43
|
+
status: 'Found',
|
|
44
|
+
userType: 'SIGNED_IN_USER',
|
|
45
|
+
checkedAt: new Date().toISOString()
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = DebugSignedInUsers;
|
|
@@ -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;
|