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.
- package/functions/computation-system-v2/README.md +152 -0
- package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +720 -0
- package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +176 -0
- package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +294 -0
- package/functions/computation-system-v2/computations/TestComputation.js +46 -0
- package/functions/computation-system-v2/computations/UserPortfolioSummary.js +172 -0
- package/functions/computation-system-v2/config/bulltrackers.config.js +317 -0
- package/functions/computation-system-v2/framework/core/Computation.js +73 -0
- package/functions/computation-system-v2/framework/core/Manifest.js +223 -0
- package/functions/computation-system-v2/framework/core/RuleInjector.js +53 -0
- package/functions/computation-system-v2/framework/core/Rules.js +231 -0
- package/functions/computation-system-v2/framework/core/RunAnalyzer.js +163 -0
- package/functions/computation-system-v2/framework/cost/CostTracker.js +154 -0
- package/functions/computation-system-v2/framework/data/DataFetcher.js +399 -0
- package/functions/computation-system-v2/framework/data/QueryBuilder.js +232 -0
- package/functions/computation-system-v2/framework/data/SchemaRegistry.js +287 -0
- package/functions/computation-system-v2/framework/execution/Orchestrator.js +498 -0
- package/functions/computation-system-v2/framework/execution/TaskRunner.js +35 -0
- package/functions/computation-system-v2/framework/execution/middleware/CostTrackerMiddleware.js +32 -0
- package/functions/computation-system-v2/framework/execution/middleware/LineageMiddleware.js +32 -0
- package/functions/computation-system-v2/framework/execution/middleware/Middleware.js +14 -0
- package/functions/computation-system-v2/framework/execution/middleware/ProfilerMiddleware.js +47 -0
- package/functions/computation-system-v2/framework/index.js +45 -0
- package/functions/computation-system-v2/framework/lineage/LineageTracker.js +147 -0
- package/functions/computation-system-v2/framework/monitoring/Profiler.js +80 -0
- package/functions/computation-system-v2/framework/resilience/Checkpointer.js +66 -0
- package/functions/computation-system-v2/framework/scheduling/ScheduleValidator.js +327 -0
- package/functions/computation-system-v2/framework/storage/StateRepository.js +286 -0
- package/functions/computation-system-v2/framework/storage/StorageManager.js +469 -0
- package/functions/computation-system-v2/framework/storage/index.js +9 -0
- package/functions/computation-system-v2/framework/testing/ComputationTester.js +86 -0
- package/functions/computation-system-v2/framework/utils/Graph.js +205 -0
- package/functions/computation-system-v2/handlers/dispatcher.js +109 -0
- package/functions/computation-system-v2/handlers/index.js +23 -0
- package/functions/computation-system-v2/handlers/onDemand.js +289 -0
- package/functions/computation-system-v2/handlers/scheduler.js +327 -0
- package/functions/computation-system-v2/index.js +163 -0
- package/functions/computation-system-v2/rules/index.js +49 -0
- package/functions/computation-system-v2/rules/instruments.js +465 -0
- package/functions/computation-system-v2/rules/metrics.js +304 -0
- package/functions/computation-system-v2/rules/portfolio.js +534 -0
- package/functions/computation-system-v2/rules/rankings.js +655 -0
- package/functions/computation-system-v2/rules/social.js +562 -0
- package/functions/computation-system-v2/rules/trades.js +545 -0
- package/functions/computation-system-v2/scripts/migrate-sectors.js +73 -0
- package/functions/computation-system-v2/test/test-dispatcher.js +317 -0
- package/functions/computation-system-v2/test/test-framework.js +500 -0
- package/functions/computation-system-v2/test/test-real-execution.js +166 -0
- package/functions/computation-system-v2/test/test-real-integration.js +194 -0
- package/functions/computation-system-v2/test/test-refactor-e2e.js +131 -0
- package/functions/computation-system-v2/test/test-results.json +31 -0
- package/functions/computation-system-v2/test/test-risk-metrics-computation.js +329 -0
- package/functions/computation-system-v2/test/test-scheduler.js +204 -0
- package/functions/computation-system-v2/test/test-storage.js +449 -0
- package/functions/orchestrator/index.js +18 -26
- 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;
|