bulltrackers-module 1.0.733 → 1.0.734

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 (56) hide show
  1. package/functions/computation-system-v2/README.md +152 -0
  2. package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +720 -0
  3. package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +176 -0
  4. package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +294 -0
  5. package/functions/computation-system-v2/computations/TestComputation.js +46 -0
  6. package/functions/computation-system-v2/computations/UserPortfolioSummary.js +172 -0
  7. package/functions/computation-system-v2/config/bulltrackers.config.js +317 -0
  8. package/functions/computation-system-v2/framework/core/Computation.js +73 -0
  9. package/functions/computation-system-v2/framework/core/Manifest.js +223 -0
  10. package/functions/computation-system-v2/framework/core/RuleInjector.js +53 -0
  11. package/functions/computation-system-v2/framework/core/Rules.js +231 -0
  12. package/functions/computation-system-v2/framework/core/RunAnalyzer.js +163 -0
  13. package/functions/computation-system-v2/framework/cost/CostTracker.js +154 -0
  14. package/functions/computation-system-v2/framework/data/DataFetcher.js +399 -0
  15. package/functions/computation-system-v2/framework/data/QueryBuilder.js +232 -0
  16. package/functions/computation-system-v2/framework/data/SchemaRegistry.js +287 -0
  17. package/functions/computation-system-v2/framework/execution/Orchestrator.js +498 -0
  18. package/functions/computation-system-v2/framework/execution/TaskRunner.js +35 -0
  19. package/functions/computation-system-v2/framework/execution/middleware/CostTrackerMiddleware.js +32 -0
  20. package/functions/computation-system-v2/framework/execution/middleware/LineageMiddleware.js +32 -0
  21. package/functions/computation-system-v2/framework/execution/middleware/Middleware.js +14 -0
  22. package/functions/computation-system-v2/framework/execution/middleware/ProfilerMiddleware.js +47 -0
  23. package/functions/computation-system-v2/framework/index.js +45 -0
  24. package/functions/computation-system-v2/framework/lineage/LineageTracker.js +147 -0
  25. package/functions/computation-system-v2/framework/monitoring/Profiler.js +80 -0
  26. package/functions/computation-system-v2/framework/resilience/Checkpointer.js +66 -0
  27. package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +327 -0
  28. package/functions/computation-system-v2/framework/storage/StateRepository.js +286 -0
  29. package/functions/computation-system-v2/framework/storage/StorageManager.js +469 -0
  30. package/functions/computation-system-v2/framework/storage/index.js +9 -0
  31. package/functions/computation-system-v2/framework/testing/ComputationTester.js +86 -0
  32. package/functions/computation-system-v2/framework/utils/Graph.js +205 -0
  33. package/functions/computation-system-v2/handlers/dispatcher.js +109 -0
  34. package/functions/computation-system-v2/handlers/index.js +23 -0
  35. package/functions/computation-system-v2/handlers/onDemand.js +289 -0
  36. package/functions/computation-system-v2/handlers/scheduler.js +327 -0
  37. package/functions/computation-system-v2/index.js +163 -0
  38. package/functions/computation-system-v2/rules/index.js +49 -0
  39. package/functions/computation-system-v2/rules/instruments.js +465 -0
  40. package/functions/computation-system-v2/rules/metrics.js +304 -0
  41. package/functions/computation-system-v2/rules/portfolio.js +534 -0
  42. package/functions/computation-system-v2/rules/rankings.js +655 -0
  43. package/functions/computation-system-v2/rules/social.js +562 -0
  44. package/functions/computation-system-v2/rules/trades.js +545 -0
  45. package/functions/computation-system-v2/scripts/migrate-sectors.js +73 -0
  46. package/functions/computation-system-v2/test/test-dispatcher.js +317 -0
  47. package/functions/computation-system-v2/test/test-framework.js +500 -0
  48. package/functions/computation-system-v2/test/test-real-execution.js +166 -0
  49. package/functions/computation-system-v2/test/test-real-integration.js +194 -0
  50. package/functions/computation-system-v2/test/test-refactor-e2e.js +131 -0
  51. package/functions/computation-system-v2/test/test-results.json +31 -0
  52. package/functions/computation-system-v2/test/test-risk-metrics-computation.js +329 -0
  53. package/functions/computation-system-v2/test/test-scheduler.js +204 -0
  54. package/functions/computation-system-v2/test/test-storage.js +449 -0
  55. package/functions/orchestrator/index.js +18 -26
  56. package/package.json +3 -2
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @fileoverview Popular Investor Risk Assessment - v2
3
+ *
4
+ * This computation DEPENDS on PopularInvestorProfileMetrics.
5
+ * It uses the profile metrics to calculate additional risk assessments.
6
+ *
7
+ * This tests:
8
+ * 1. Dependency declaration works
9
+ * 2. Pass assignment is incremented (should be Pass 2)
10
+ * 3. Dependency data is loaded and passed to process()
11
+ */
12
+
13
+ const { Computation } = require('../framework');
14
+
15
+ class PopularInvestorRiskAssessment extends Computation {
16
+
17
+ static getConfig() {
18
+ return {
19
+ name: 'PopularInvestorRiskAssessment',
20
+ category: 'popular_investor',
21
+
22
+ // Only needs rankings for additional risk factors
23
+ requires: {
24
+ 'pi_rankings': {
25
+ lookback: 0,
26
+ mandatory: true,
27
+ filter: null
28
+ }
29
+ },
30
+
31
+ // DEPENDS ON PopularInvestorProfileMetrics!
32
+ dependencies: ['PopularInvestorProfileMetrics'],
33
+
34
+ type: 'per-entity',
35
+ isHistorical: false,
36
+ ttlDays: 30
37
+ };
38
+ }
39
+
40
+ static getSchema() {
41
+ return {
42
+ type: 'object',
43
+ properties: {
44
+ riskScore: { type: 'number' },
45
+ riskLevel: { type: 'string' },
46
+ diversificationScore: { type: 'number' },
47
+ volatilityScore: { type: 'number' },
48
+ concentrationRisk: { type: 'number' },
49
+ sectorConcentration: { type: 'object' },
50
+ recommendations: { type: 'array' }
51
+ }
52
+ };
53
+ }
54
+
55
+ static getWeight() {
56
+ return 3.0;
57
+ }
58
+
59
+ async process(context) {
60
+ const { date, entityId: userId, data, dependencies } = context;
61
+
62
+ // Get the profile metrics from our dependency
63
+ const profileMetrics = dependencies?.['popularinvestorprofilemetrics']?.[userId];
64
+
65
+ // Get rankings data
66
+ const rankingsData = data['pi_rankings'];
67
+ const rankEntry = this._findRankEntry(rankingsData, userId);
68
+
69
+ // Initialize result
70
+ const result = {
71
+ riskScore: 0,
72
+ riskLevel: 'Unknown',
73
+ diversificationScore: 0,
74
+ volatilityScore: 0,
75
+ concentrationRisk: 0,
76
+ sectorConcentration: {},
77
+ recommendations: []
78
+ };
79
+
80
+ // If we don't have profile metrics, we can still calculate basic risk from rankings
81
+ if (rankEntry) {
82
+ const riskScore = rankEntry.RiskScore || rankEntry.riskScore || 0;
83
+ result.riskScore = riskScore;
84
+ result.riskLevel = this._getRiskLevel(riskScore);
85
+ }
86
+
87
+ // If we have profile metrics, enhance the assessment
88
+ if (profileMetrics) {
89
+ // Use sector exposure for diversification analysis
90
+ const sectorExposure = profileMetrics.sectorExposure?.data || {};
91
+ const sectors = Object.keys(sectorExposure);
92
+
93
+ if (sectors.length > 0) {
94
+ // Diversification score (more sectors = better diversification)
95
+ result.diversificationScore = Math.min(100, sectors.length * 15);
96
+
97
+ // Concentration risk (highest sector weight)
98
+ const maxExposure = Math.max(...Object.values(sectorExposure), 0);
99
+ result.concentrationRisk = maxExposure;
100
+
101
+ // Copy sector breakdown
102
+ result.sectorConcentration = sectorExposure;
103
+
104
+ // Generate recommendations
105
+ if (maxExposure > 50) {
106
+ result.recommendations.push(`High concentration in single sector (${maxExposure.toFixed(1)}%). Consider diversifying.`);
107
+ }
108
+ if (sectors.length < 3) {
109
+ result.recommendations.push('Portfolio is concentrated in few sectors. Consider adding exposure to other sectors.');
110
+ }
111
+ }
112
+
113
+ // Use portfolio summary for additional risk factors
114
+ const portfolioSummary = profileMetrics.portfolioSummary || {};
115
+ if (portfolioSummary.profitPercent < -10) {
116
+ result.recommendations.push('Portfolio showing significant losses. Review position sizing.');
117
+ }
118
+
119
+ // Use rankings data from profile for win ratio analysis
120
+ const rankingsFromProfile = profileMetrics.rankingsData || {};
121
+ if (rankingsFromProfile.winRatio < 0.5) {
122
+ result.volatilityScore = 80; // Lower win ratio = higher volatility risk
123
+ result.recommendations.push('Win ratio below 50%. Trading strategy may need review.');
124
+ } else {
125
+ result.volatilityScore = Math.max(0, 100 - (rankingsFromProfile.winRatio * 100));
126
+ }
127
+
128
+ // Adjust overall risk score based on analysis
129
+ const adjustedRisk = (
130
+ (result.riskScore * 0.4) +
131
+ (result.concentrationRisk * 0.3) +
132
+ (result.volatilityScore * 0.3)
133
+ );
134
+ result.riskScore = Math.round(adjustedRisk);
135
+ result.riskLevel = this._getRiskLevel(result.riskScore);
136
+ }
137
+
138
+ this.setResult(userId, result);
139
+ }
140
+
141
+ _findRankEntry(rankings, userId) {
142
+ if (!rankings) return null;
143
+
144
+ // Rankings might be date-keyed or an array
145
+ if (typeof rankings === 'object' && !Array.isArray(rankings)) {
146
+ const dates = Object.keys(rankings).sort().reverse();
147
+ for (const dateStr of dates) {
148
+ const dayRankings = rankings[dateStr];
149
+ if (Array.isArray(dayRankings)) {
150
+ const entry = dayRankings.find(r =>
151
+ String(r.CustomerId || r.pi_id || r.userId) === String(userId)
152
+ );
153
+ if (entry) return entry;
154
+ }
155
+ }
156
+ }
157
+
158
+ if (Array.isArray(rankings)) {
159
+ return rankings.find(r =>
160
+ String(r.CustomerId || r.pi_id || r.userId) === String(userId)
161
+ );
162
+ }
163
+
164
+ return null;
165
+ }
166
+
167
+ _getRiskLevel(score) {
168
+ if (score <= 2) return 'Very Low';
169
+ if (score <= 4) return 'Low';
170
+ if (score <= 6) return 'Medium';
171
+ if (score <= 8) return 'High';
172
+ return 'Very High';
173
+ }
174
+ }
175
+
176
+ module.exports = PopularInvestorRiskAssessment;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * @fileoverview Popular Investor Risk Metrics Computation
3
+ *
4
+ * Calculates comprehensive risk metrics for each popular investor:
5
+ * - Sharpe Ratio (risk-adjusted returns)
6
+ * - Sortino Ratio (downside risk)
7
+ * - Max Drawdown
8
+ * - Win Ratio
9
+ * - Portfolio concentration
10
+ *
11
+ * This computation demonstrates:
12
+ * - Using both metrics and portfolio rules
13
+ * - Per-entity computation pattern
14
+ * - Dual storage (BigQuery + Firestore)
15
+ * - User-centric Firestore path
16
+ */
17
+
18
+ const { Computation } = require('../framework');
19
+
20
+ class PopularInvestorRiskMetrics extends Computation {
21
+ static getConfig() {
22
+ return {
23
+ name: 'PopularInvestorRiskMetrics',
24
+ category: 'risk_analytics',
25
+
26
+ // Data requirements
27
+ requires: {
28
+ 'portfolio_snapshots': {
29
+ lookback: 30, // Need 30 days of history for risk metrics
30
+ mandatory: true,
31
+ filter: { user_type: 'POPULAR_INVESTOR' }
32
+ },
33
+ 'trade_history_snapshots': {
34
+ lookback: 30,
35
+ mandatory: false // Optional, used for trade-based metrics
36
+ }
37
+ },
38
+
39
+ // No computation dependencies
40
+ dependencies: [],
41
+
42
+ // Per-entity means we process each user separately
43
+ type: 'per-entity',
44
+
45
+ // Keep 90 days of history
46
+ ttlDays: 90,
47
+
48
+ // Schedule: Run daily at 3 AM
49
+ schedule: {
50
+ frequency: 'daily',
51
+ time: '03:00'
52
+ },
53
+
54
+ // =========================================================================
55
+ // STORAGE: Write to both BigQuery and Firestore
56
+ // =========================================================================
57
+ storage: {
58
+ // BigQuery: for analytics queries and as dependency for other computations
59
+ bigquery: true,
60
+
61
+ // Firestore: for real-time frontend access
62
+ // Each user gets their risk metrics in their user document
63
+ firestore: {
64
+ enabled: true,
65
+ path: '/users/{entityId}/computations/risk_metrics',
66
+ merge: false, // Overwrite the entire document
67
+ includeMetadata: true // Include _computedAt, _codeHash, etc.
68
+ }
69
+ }
70
+ };
71
+ }
72
+
73
+ static getSchema() {
74
+ return {
75
+ type: 'object',
76
+ properties: {
77
+ userId: { type: 'string' },
78
+ date: { type: 'string' },
79
+
80
+ // Portfolio overview
81
+ portfolioValue: { type: 'number' },
82
+ positionCount: { type: 'number' },
83
+
84
+ // Risk metrics
85
+ sharpeRatio: { type: 'number' },
86
+ sortinoRatio: { type: 'number' },
87
+ maxDrawdown: { type: 'number' },
88
+ valueAtRisk95: { type: 'number' },
89
+
90
+ // Performance metrics
91
+ winRatio: { type: 'number' },
92
+ avgDailyReturn: { type: 'number' },
93
+ returnVolatility: { type: 'number' },
94
+
95
+ // Concentration metrics
96
+ top3Concentration: { type: 'number' },
97
+
98
+ // Risk grade (A-F)
99
+ riskGrade: { type: 'string' }
100
+ },
101
+ required: ['userId', 'date', 'riskGrade']
102
+ };
103
+ }
104
+
105
+ static getWeight() {
106
+ return 2.0; // Higher weight = more expensive
107
+ }
108
+
109
+ /**
110
+ * Process a single investor's risk metrics.
111
+ */
112
+ async process(context) {
113
+ const { date, entityId, data, rules } = context;
114
+
115
+ const portfolioHistory = data['portfolio_snapshots'];
116
+
117
+ // Need portfolio history to calculate risk metrics
118
+ if (!portfolioHistory || !Array.isArray(portfolioHistory) || portfolioHistory.length === 0) {
119
+ this.log('DEBUG', `No portfolio history for user ${entityId}`);
120
+ this.setResult(entityId, this._emptyResult(entityId, date));
121
+ return;
122
+ }
123
+
124
+ // Sort by date ascending
125
+ const sortedHistory = [...portfolioHistory].sort((a, b) =>
126
+ new Date(a.date || a.snapshot_date) - new Date(b.date || b.snapshot_date)
127
+ );
128
+
129
+ // =====================================================================
130
+ // STEP 1: Extract daily portfolio values and calculate returns
131
+ // =====================================================================
132
+
133
+ const dailyValues = sortedHistory.map(snapshot => {
134
+ const positions = rules.portfolio.extractPositions(snapshot);
135
+ return rules.portfolio.calculateTotalValue(positions);
136
+ });
137
+
138
+ // Calculate daily returns (percentage change)
139
+ const dailyReturns = [];
140
+ for (let i = 1; i < dailyValues.length; i++) {
141
+ if (dailyValues[i - 1] > 0) {
142
+ const returnPct = ((dailyValues[i] - dailyValues[i - 1]) / dailyValues[i - 1]) * 100;
143
+ dailyReturns.push(returnPct);
144
+ }
145
+ }
146
+
147
+ // Need at least a few returns for meaningful metrics
148
+ if (dailyReturns.length < 5) {
149
+ this.log('DEBUG', `Insufficient return history for user ${entityId}: ${dailyReturns.length} days`);
150
+ this.setResult(entityId, this._emptyResult(entityId, date));
151
+ return;
152
+ }
153
+
154
+ // =====================================================================
155
+ // STEP 2: Calculate risk metrics using rules.metrics
156
+ // =====================================================================
157
+
158
+ // Sharpe Ratio (risk-free rate = 4% annualized)
159
+ const sharpeRatio = rules.metrics.calculateSharpeRatio(dailyReturns, 4, 252);
160
+
161
+ // Sortino Ratio (target return = 0)
162
+ const sortinoRatio = rules.metrics.calculateSortinoRatio(dailyReturns, 0, 252);
163
+
164
+ // Max Drawdown
165
+ const { maxDrawdown } = rules.metrics.calculateMaxDrawdown(dailyValues);
166
+
167
+ // Value at Risk (95% confidence)
168
+ const valueAtRisk95 = rules.metrics.calculateVaR(dailyReturns, 0.95);
169
+
170
+ // Basic statistics
171
+ const avgDailyReturn = rules.metrics.mean(dailyReturns);
172
+ const returnVolatility = rules.metrics.standardDeviation(dailyReturns);
173
+
174
+ // =====================================================================
175
+ // STEP 3: Calculate portfolio metrics using rules.portfolio
176
+ // =====================================================================
177
+
178
+ // Get latest portfolio snapshot
179
+ const latestSnapshot = sortedHistory[sortedHistory.length - 1];
180
+ const positions = rules.portfolio.extractPositions(latestSnapshot);
181
+
182
+ const portfolioValue = rules.portfolio.calculateTotalValue(positions);
183
+ const positionCount = positions.length;
184
+ const winRatio = rules.portfolio.calculateWinRatio(positions);
185
+
186
+ // Calculate concentration (top 3 positions as % of portfolio)
187
+ const top3 = rules.portfolio.getTopPositions(positions, 3);
188
+ const top3Value = top3.reduce((sum, pos) => sum + rules.portfolio.getValue(pos), 0);
189
+ const top3Concentration = portfolioValue > 0 ? (top3Value / portfolioValue) * 100 : 0;
190
+
191
+ // =====================================================================
192
+ // STEP 4: Calculate risk grade (A-F based on combined metrics)
193
+ // =====================================================================
194
+
195
+ const riskGrade = this._calculateRiskGrade({
196
+ sharpeRatio,
197
+ sortinoRatio,
198
+ maxDrawdown,
199
+ winRatio,
200
+ top3Concentration
201
+ }, rules);
202
+
203
+ // =====================================================================
204
+ // STEP 5: Build and store result
205
+ // =====================================================================
206
+
207
+ this.setResult(entityId, {
208
+ userId: entityId,
209
+ date,
210
+
211
+ // Portfolio overview
212
+ portfolioValue: rules.metrics.round(portfolioValue, 2),
213
+ positionCount,
214
+
215
+ // Risk metrics (annualized where applicable)
216
+ sharpeRatio: rules.metrics.round(sharpeRatio, 3),
217
+ sortinoRatio: rules.metrics.round(sortinoRatio, 3),
218
+ maxDrawdown: rules.metrics.round(maxDrawdown, 2),
219
+ valueAtRisk95: rules.metrics.round(valueAtRisk95, 2),
220
+
221
+ // Performance
222
+ winRatio: rules.metrics.round(winRatio, 4),
223
+ avgDailyReturn: rules.metrics.round(avgDailyReturn, 4),
224
+ returnVolatility: rules.metrics.round(returnVolatility, 4),
225
+
226
+ // Concentration
227
+ top3Concentration: rules.metrics.round(top3Concentration, 2),
228
+
229
+ // Overall grade
230
+ riskGrade,
231
+
232
+ // Metadata for debugging
233
+ daysAnalyzed: dailyReturns.length + 1
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Calculate a risk grade from A (best) to F (worst).
239
+ */
240
+ _calculateRiskGrade(metrics, rules) {
241
+ let score = 50; // Start at middle
242
+
243
+ // Sharpe Ratio: > 2 is excellent, < 0.5 is poor
244
+ if (metrics.sharpeRatio > 2) score += 20;
245
+ else if (metrics.sharpeRatio > 1) score += 10;
246
+ else if (metrics.sharpeRatio < 0.5) score -= 10;
247
+ else if (metrics.sharpeRatio < 0) score -= 20;
248
+
249
+ // Max Drawdown: < 10% is good, > 30% is poor
250
+ if (metrics.maxDrawdown < 10) score += 15;
251
+ else if (metrics.maxDrawdown < 20) score += 5;
252
+ else if (metrics.maxDrawdown > 30) score -= 15;
253
+ else if (metrics.maxDrawdown > 50) score -= 25;
254
+
255
+ // Win Ratio: > 60% is good, < 40% is poor
256
+ if (metrics.winRatio > 0.6) score += 10;
257
+ else if (metrics.winRatio < 0.4) score -= 10;
258
+
259
+ // Concentration: > 50% in top 3 is risky
260
+ if (metrics.top3Concentration > 70) score -= 15;
261
+ else if (metrics.top3Concentration > 50) score -= 5;
262
+ else if (metrics.top3Concentration < 30) score += 5;
263
+
264
+ // Clamp and convert to grade
265
+ score = rules.metrics.clamp(score, 0, 100);
266
+
267
+ if (score >= 80) return 'A';
268
+ if (score >= 65) return 'B';
269
+ if (score >= 50) return 'C';
270
+ if (score >= 35) return 'D';
271
+ return 'F';
272
+ }
273
+
274
+ _emptyResult(entityId, date) {
275
+ return {
276
+ userId: entityId,
277
+ date,
278
+ portfolioValue: 0,
279
+ positionCount: 0,
280
+ sharpeRatio: 0,
281
+ sortinoRatio: 0,
282
+ maxDrawdown: 0,
283
+ valueAtRisk95: 0,
284
+ winRatio: 0,
285
+ avgDailyReturn: 0,
286
+ returnVolatility: 0,
287
+ top3Concentration: 0,
288
+ riskGrade: 'N/A',
289
+ daysAnalyzed: 0
290
+ };
291
+ }
292
+ }
293
+
294
+ module.exports = PopularInvestorRiskMetrics;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @fileoverview Test Computation for E2E Verification
3
+ */
4
+ const { Computation } = require('../framework');
5
+
6
+ class TestComputation extends Computation {
7
+ static getConfig() {
8
+ return {
9
+ name: 'TestComputation',
10
+ version: '1.0.0',
11
+ type: 'per-entity', // Forces the Streaming/Batching path
12
+ schedule: 'daily',
13
+ requires: {
14
+ // We will mock these tables in the test
15
+ users: { fields: ['id', 'status'], mandatory: true },
16
+ transactions: { fields: ['userId', 'amount'], mandatory: false }
17
+ },
18
+ dependencies: []
19
+ };
20
+ }
21
+
22
+ async process(context) {
23
+ const { entityId, data, rules } = context;
24
+
25
+ // 1. Validate Data Injection
26
+ const user = data.users;
27
+ const txs = data.transactions || []; // Should be array of transactions for this user
28
+
29
+ // 2. Validate Rule Injection (Dynamic)
30
+ // We expect the test framework to provide a mock rule named 'math'
31
+ const multiplier = rules.math ? rules.math.double(1) : 1;
32
+
33
+ // 3. Perform Calculation
34
+ const totalAmount = txs.reduce((sum, t) => sum + t.amount, 0);
35
+
36
+ // 4. Return Result
37
+ this.results[entityId] = {
38
+ userId: entityId,
39
+ score: totalAmount * multiplier,
40
+ status: user.status,
41
+ processedAt: new Date().toISOString()
42
+ };
43
+ }
44
+ }
45
+
46
+ module.exports = TestComputation;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @fileoverview User Portfolio Summary - v2 Computation
3
+ *
4
+ * This computation demonstrates the "simple recipe" pattern:
5
+ * - No complex calculations inline
6
+ * - Uses injected rules for all business logic
7
+ * - The computation itself just orchestrates
8
+ *
9
+ * When rules.portfolio changes, this computation automatically re-runs
10
+ * because the rules hash is included in the computation hash.
11
+ */
12
+
13
+ const { Computation } = require('../framework');
14
+
15
+ class UserPortfolioSummary extends Computation {
16
+ /**
17
+ * Configuration - What the framework needs to know.
18
+ */
19
+ static getConfig() {
20
+ return {
21
+ name: 'UserPortfolioSummary',
22
+ category: 'analytics',
23
+
24
+ requires: {
25
+ 'portfolio_snapshots': {
26
+ lookback: 0,
27
+ mandatory: true,
28
+ filter: { user_type: 'POPULAR_INVESTOR' }
29
+ },
30
+ 'asset_prices': {
31
+ lookback: 0,
32
+ mandatory: true
33
+ }
34
+ },
35
+
36
+ dependencies: [],
37
+ type: 'per-entity',
38
+ ttlDays: 30,
39
+
40
+ // =========================================================================
41
+ // STORAGE CONFIGURATION
42
+ // =========================================================================
43
+ // Controls where computation results are persisted.
44
+ // BigQuery is always enabled unless explicitly set to false.
45
+ // Firestore is opt-in for frontend-facing, user-centric data.
46
+ // =========================================================================
47
+ storage: {
48
+ // BigQuery: always enabled by default (for analytics, dependency resolution)
49
+ bigquery: true,
50
+
51
+ // Firestore: user-centric storage for real-time frontend access
52
+ firestore: {
53
+ enabled: true,
54
+ // Path template - {entityId} replaced with user ID, {date} with computation date
55
+ // This stores each user's portfolio summary in their user document
56
+ path: '/users/{entityId}/computations/portfolio_summary',
57
+
58
+ // merge: false = overwrite entire document (default)
59
+ // merge: true = merge with existing fields
60
+ merge: false,
61
+
62
+ // Include metadata (_computedAt, _computationDate, _codeHash)
63
+ includeMetadata: true
64
+ }
65
+ }
66
+ };
67
+ }
68
+
69
+ static getSchema() {
70
+ return {
71
+ type: 'object',
72
+ properties: {
73
+ userId: { type: 'string' },
74
+ date: { type: 'string' },
75
+ totalValue: { type: 'number' },
76
+ totalInvested: { type: 'number' },
77
+ profitLoss: { type: 'number' },
78
+ profitLossPercent: { type: 'number' },
79
+ positionCount: { type: 'number' },
80
+ winRatio: { type: 'number' },
81
+ topPositions: { type: 'array' }
82
+ },
83
+ required: ['userId', 'date', 'totalValue']
84
+ };
85
+ }
86
+
87
+ static getWeight() {
88
+ return 1.5;
89
+ }
90
+
91
+ /**
92
+ * Process a single user's portfolio.
93
+ *
94
+ * NOTE: This is a "simple recipe" - it uses injected rules for all
95
+ * business logic. The computation just orchestrates the rules.
96
+ *
97
+ * Benefits:
98
+ * - Rules are the single source of truth
99
+ * - When rules change, this computation re-runs automatically
100
+ * - Easy to understand what calculations are being done
101
+ * - Easy to test rules in isolation
102
+ */
103
+ async process(context) {
104
+ const { date, entityId, data, rules } = context;
105
+
106
+ const portfolio = data['portfolio_snapshots'];
107
+ const prices = data['asset_prices'];
108
+
109
+ // No data = no result
110
+ if (!portfolio) {
111
+ this.log('DEBUG', `No portfolio for user ${entityId}`);
112
+ return;
113
+ }
114
+
115
+ // =====================================================================
116
+ // USE INJECTED RULES - No inline business logic!
117
+ // =====================================================================
118
+
119
+ // Extract positions using portfolio rules
120
+ const positions = rules.portfolio.extractPositions(portfolio);
121
+
122
+ if (!positions || positions.length === 0) {
123
+ this.setResult(entityId, this._emptyResult(entityId, date));
124
+ return;
125
+ }
126
+
127
+ // Calculate portfolio metrics using portfolio rules
128
+ const totalValue = rules.portfolio.calculateTotalValue(positions);
129
+ const totalInvested = rules.portfolio.calculateTotalInvested(positions);
130
+ const profitLossPercent = rules.portfolio.calculateWeightedProfitPercent(positions);
131
+ const winRatio = rules.portfolio.calculateWinRatio(positions);
132
+ const topPositions = rules.portfolio.getTopPositions(positions, 5);
133
+
134
+ // Use metrics rules for rounding
135
+ const profitLoss = rules.metrics.round(totalValue - totalInvested, 2);
136
+
137
+ // Build result
138
+ this.setResult(entityId, {
139
+ userId: entityId,
140
+ date,
141
+ totalValue: rules.metrics.round(totalValue, 2),
142
+ totalInvested: rules.metrics.round(totalInvested, 2),
143
+ profitLoss,
144
+ profitLossPercent: rules.metrics.round(profitLossPercent, 2),
145
+ positionCount: positions.length,
146
+ winRatio: rules.metrics.round(winRatio, 4),
147
+ topPositions: topPositions.map(pos => ({
148
+ instrumentId: rules.portfolio.getInstrumentId(pos),
149
+ value: rules.metrics.round(rules.portfolio.getValue(pos), 2),
150
+ percentOfPortfolio: totalValue > 0
151
+ ? rules.metrics.round((rules.portfolio.getValue(pos) / totalValue) * 100, 2)
152
+ : 0
153
+ }))
154
+ });
155
+ }
156
+
157
+ _emptyResult(entityId, date) {
158
+ return {
159
+ userId: entityId,
160
+ date,
161
+ totalValue: 0,
162
+ totalInvested: 0,
163
+ profitLoss: 0,
164
+ profitLossPercent: 0,
165
+ positionCount: 0,
166
+ winRatio: 0,
167
+ topPositions: []
168
+ };
169
+ }
170
+ }
171
+
172
+ module.exports = UserPortfolioSummary;