bulltrackers-module 1.0.765 → 1.0.768
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/functions/computation-system-v2/computations/BehavioralAnomaly.js +298 -186
- 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/PopularInvestorProfileMetrics.js +354 -641
- package/functions/computation-system-v2/config/bulltrackers.config.js +26 -14
- 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 +142 -4
- package/functions/computation-system-v2/framework/execution/Orchestrator.js +119 -122
- package/functions/computation-system-v2/framework/storage/StorageManager.js +16 -18
- package/functions/computation-system-v2/framework/testing/ComputationTester.js +155 -66
- package/functions/computation-system-v2/handlers/scheduler.js +15 -5
- package/functions/computation-system-v2/scripts/test-computation-dag.js +109 -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
|
@@ -1,17 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview New Sector Exposure Alert
|
|
3
|
+
* Refactored to map InstrumentID -> Ticker -> Sector (Industry)
|
|
4
|
+
* UPDATE: Uses "Last Known" logic for previous state (Lookback 14 days)
|
|
5
|
+
* Confirmed working 29/01/2026
|
|
6
|
+
*/
|
|
1
7
|
const { Computation } = require('../framework');
|
|
2
8
|
|
|
3
9
|
class NewSectorExposure extends Computation {
|
|
4
10
|
|
|
5
11
|
constructor() {
|
|
6
12
|
super();
|
|
7
|
-
this.SECTOR_MAPPING = {
|
|
8
|
-
1: 'Currencies',
|
|
9
|
-
2: 'Commodities',
|
|
10
|
-
4: 'Indices',
|
|
11
|
-
5: 'Stocks',
|
|
12
|
-
6: 'ETF',
|
|
13
|
-
10: 'Cryptocurrencies'
|
|
14
|
-
};
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
static getConfig() {
|
|
@@ -23,9 +21,15 @@ class NewSectorExposure extends Computation {
|
|
|
23
21
|
|
|
24
22
|
requires: {
|
|
25
23
|
'portfolio_snapshots': {
|
|
26
|
-
lookback:
|
|
24
|
+
lookback: 14, // INCREASED: Look back 2 weeks to find last known state
|
|
27
25
|
mandatory: true,
|
|
28
26
|
fields: ['user_id', 'portfolio_data', 'date']
|
|
27
|
+
},
|
|
28
|
+
'ticker_mappings': {
|
|
29
|
+
mandatory: false
|
|
30
|
+
},
|
|
31
|
+
'sector_mappings': {
|
|
32
|
+
mandatory: false
|
|
29
33
|
}
|
|
30
34
|
},
|
|
31
35
|
|
|
@@ -38,12 +42,11 @@ class NewSectorExposure extends Computation {
|
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
|
|
41
|
-
// LEGACY METADATA
|
|
42
45
|
userType: 'POPULAR_INVESTOR',
|
|
43
46
|
alert: {
|
|
44
47
|
id: 'newSector',
|
|
45
48
|
frontendName: 'New Sector Entry',
|
|
46
|
-
description: 'Alert when a Popular Investor enters a new sector
|
|
49
|
+
description: 'Alert when a Popular Investor enters a new industry sector',
|
|
47
50
|
messageTemplate: '{piUsername} entered new sector(s): {sectorName}',
|
|
48
51
|
severity: 'low',
|
|
49
52
|
configKey: 'newSector',
|
|
@@ -55,7 +58,7 @@ class NewSectorExposure extends Computation {
|
|
|
55
58
|
type: 'multiselect',
|
|
56
59
|
label: 'Watch specific sectors',
|
|
57
60
|
description: 'Only alert for these sectors',
|
|
58
|
-
options: ['
|
|
61
|
+
options: ['Technology', 'Basic Materials', 'Consumer Cyclical', 'Financial Services', 'Real Estate', 'Healthcare', 'Energy', 'Industrials'],
|
|
59
62
|
default: []
|
|
60
63
|
}
|
|
61
64
|
],
|
|
@@ -70,49 +73,90 @@ class NewSectorExposure extends Computation {
|
|
|
70
73
|
async process(context) {
|
|
71
74
|
const { data, entityId, date, rules } = context;
|
|
72
75
|
|
|
73
|
-
// 1. Get Data
|
|
76
|
+
// 1. Get Data Sources
|
|
74
77
|
const history = data['portfolio_snapshots'] || [];
|
|
75
|
-
|
|
78
|
+
// FIX: Handle DataFetcher returning Objects (keyed by ID) or Arrays.
|
|
79
|
+
const tickerRows = Object.values(data['ticker_mappings'] || {});
|
|
80
|
+
const sectorRows = Object.values(data['sector_mappings'] || {});
|
|
81
|
+
|
|
82
|
+
// 2. Build Mapping Dictionaries
|
|
83
|
+
const tickerMap = new Map();
|
|
84
|
+
tickerRows.forEach(row => {
|
|
85
|
+
if (row.instrument_id && row.ticker) {
|
|
86
|
+
tickerMap.set(Number(row.instrument_id), row.ticker.toUpperCase());
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const sectorMap = new Map();
|
|
91
|
+
sectorRows.forEach(row => {
|
|
92
|
+
if (row.symbol && row.sector && row.sector !== 'N/A') {
|
|
93
|
+
sectorMap.set(row.symbol.toUpperCase(), row.sector);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const resolveSector = (instrumentId) => {
|
|
98
|
+
const ticker = tickerMap.get(Number(instrumentId));
|
|
99
|
+
if (!ticker) return null;
|
|
100
|
+
return sectorMap.get(ticker);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 3. Helper to handle BigQuery Dates
|
|
104
|
+
const toDateStr = (d) => {
|
|
105
|
+
if (!d) return null;
|
|
106
|
+
if (d.value) return d.value;
|
|
107
|
+
return d;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// 4. Find Today and Last Known Previous Row
|
|
111
|
+
// Sort history descending by date to find the most recent previous entry
|
|
112
|
+
history.sort((a, b) => {
|
|
113
|
+
const dateA = toDateStr(a.date);
|
|
114
|
+
const dateB = toDateStr(b.date);
|
|
115
|
+
return dateB.localeCompare(dateA); // Descending (Newest First)
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const todayRow = history.find(d => toDateStr(d.date) === date);
|
|
76
119
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const yDateStr = yDate.toISOString().split('T')[0];
|
|
81
|
-
const yesterdayRow = history.find(d => d.date === yDateStr);
|
|
120
|
+
// Find the first row where date is strictly less than target date
|
|
121
|
+
// Since we sorted descending, this is the "Last Known" date
|
|
122
|
+
const previousRow = history.find(d => toDateStr(d.date) < date);
|
|
82
123
|
|
|
83
|
-
//
|
|
124
|
+
// 5. Extract Portfolio Data
|
|
84
125
|
const todayData = rules.portfolio.extractPortfolioData(todayRow);
|
|
85
|
-
const
|
|
126
|
+
const previousData = rules.portfolio.extractPortfolioData(previousRow);
|
|
86
127
|
|
|
87
128
|
if (!todayData) return;
|
|
88
129
|
|
|
89
|
-
//
|
|
130
|
+
// 6. Calculate Sector Exposures
|
|
90
131
|
const getSectorMap = (pData) => {
|
|
91
132
|
const map = new Map();
|
|
92
|
-
|
|
93
|
-
const aggs = rules.portfolio.extractPositionsByType(pData);
|
|
133
|
+
const positions = rules.portfolio.extractPositions(pData);
|
|
94
134
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const invested =
|
|
135
|
+
positions.forEach(pos => {
|
|
136
|
+
const instrumentId = rules.portfolio.getInstrumentId(pos);
|
|
137
|
+
const invested = rules.portfolio.getInvested(pos);
|
|
98
138
|
|
|
99
|
-
if (
|
|
100
|
-
const sectorName =
|
|
101
|
-
if (sectorName)
|
|
139
|
+
if (instrumentId && invested > 0) {
|
|
140
|
+
const sectorName = resolveSector(instrumentId);
|
|
141
|
+
if (sectorName) {
|
|
142
|
+
const current = map.get(sectorName) || 0;
|
|
143
|
+
map.set(sectorName, current + invested);
|
|
144
|
+
}
|
|
102
145
|
}
|
|
103
146
|
});
|
|
104
147
|
return map;
|
|
105
148
|
};
|
|
106
149
|
|
|
107
150
|
const todaySectors = getSectorMap(todayData);
|
|
108
|
-
const
|
|
151
|
+
const previousSectors = getSectorMap(previousData);
|
|
109
152
|
|
|
153
|
+
// 7. Compare
|
|
110
154
|
const newExposures = [];
|
|
111
|
-
const isBaselineReset = (!
|
|
155
|
+
const isBaselineReset = (!previousData); // True if no previous history found
|
|
112
156
|
|
|
113
157
|
if (!isBaselineReset) {
|
|
114
158
|
for (const [sectorName, invested] of todaySectors) {
|
|
115
|
-
const prevInvested =
|
|
159
|
+
const prevInvested = previousSectors.get(sectorName) || 0;
|
|
116
160
|
if (prevInvested === 0 && invested > 0) {
|
|
117
161
|
newExposures.push(sectorName);
|
|
118
162
|
}
|
|
@@ -121,11 +165,14 @@ class NewSectorExposure extends Computation {
|
|
|
121
165
|
|
|
122
166
|
const result = {
|
|
123
167
|
currentSectors: Array.from(todaySectors.keys()),
|
|
124
|
-
previousSectors: isBaselineReset ? [] : Array.from(
|
|
168
|
+
previousSectors: isBaselineReset ? [] : Array.from(previousSectors.keys()),
|
|
125
169
|
newExposures,
|
|
126
170
|
sectorName: newExposures.join(', '),
|
|
127
171
|
isBaselineReset,
|
|
128
|
-
triggered: newExposures.length > 0
|
|
172
|
+
triggered: newExposures.length > 0,
|
|
173
|
+
_metadata: {
|
|
174
|
+
previousDate: previousRow ? toDateStr(previousRow.date) : null
|
|
175
|
+
}
|
|
129
176
|
};
|
|
130
177
|
|
|
131
178
|
this.setResult(entityId, result);
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview New Social Post Alert
|
|
3
|
+
* Logic: Alerts ONLY if a post exists with a creation date matching the current run date.
|
|
4
|
+
* Relies on DataFetcher V2.8+ to handle JSON parsing/double-encoding.
|
|
5
|
+
*/
|
|
1
6
|
const { Computation } = require('../framework');
|
|
2
7
|
|
|
3
8
|
class NewSocialPost extends Computation {
|
|
@@ -7,11 +12,11 @@ class NewSocialPost extends Computation {
|
|
|
7
12
|
name: 'NewSocialPost',
|
|
8
13
|
type: 'per-entity',
|
|
9
14
|
category: 'alerts',
|
|
10
|
-
isHistorical:
|
|
15
|
+
isHistorical: true,
|
|
11
16
|
|
|
12
17
|
requires: {
|
|
13
18
|
'social_post_snapshots': {
|
|
14
|
-
lookback:
|
|
19
|
+
lookback: 5, // Short lookback just to ensure we catch the specific row
|
|
15
20
|
mandatory: true,
|
|
16
21
|
fields: ['user_id', 'posts_data', 'date']
|
|
17
22
|
}
|
|
@@ -26,19 +31,15 @@ class NewSocialPost extends Computation {
|
|
|
26
31
|
}
|
|
27
32
|
},
|
|
28
33
|
|
|
29
|
-
// LEGACY METADATA
|
|
30
34
|
userType: 'POPULAR_INVESTOR',
|
|
31
35
|
alert: {
|
|
32
36
|
id: 'newSocialPost',
|
|
33
37
|
frontendName: 'New Social Post',
|
|
34
|
-
description: 'Alert when a Popular Investor makes a new social post',
|
|
38
|
+
description: 'Alert when a Popular Investor makes a new social post today',
|
|
35
39
|
messageTemplate: '{piUsername} posted a new update: {title}',
|
|
36
40
|
severity: 'low',
|
|
37
41
|
configKey: 'newSocialPost',
|
|
38
|
-
isDynamic: false
|
|
39
|
-
thresholds: [],
|
|
40
|
-
conditions: [],
|
|
41
|
-
resultKeys: ['hasNewPost', 'latestPostDate', 'postCount', 'title']
|
|
42
|
+
isDynamic: false
|
|
42
43
|
}
|
|
43
44
|
};
|
|
44
45
|
}
|
|
@@ -46,50 +47,77 @@ class NewSocialPost extends Computation {
|
|
|
46
47
|
async process(context) {
|
|
47
48
|
const { data, entityId, date, rules } = context;
|
|
48
49
|
|
|
49
|
-
// 1. Get
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// 1. Get History
|
|
51
|
+
const history = data['social_post_snapshots'] || [];
|
|
52
|
+
|
|
53
|
+
// Helper: Handle BigQuery Date Objects
|
|
54
|
+
const toDateStr = (d) => {
|
|
55
|
+
if (!d) return null;
|
|
56
|
+
if (d.value) return d.value;
|
|
57
|
+
return d;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// 2. Find the Snapshot for "Today" (The Execution Date)
|
|
61
|
+
// We strictly want to know if they posted *on this specific date*.
|
|
62
|
+
const todayRow = history.find(d => toDateStr(d.date) === date);
|
|
63
|
+
|
|
64
|
+
if (!todayRow) {
|
|
65
|
+
// No snapshot exists for this date yet, so we can't determine if they posted.
|
|
66
|
+
this.setResult(entityId, {
|
|
67
|
+
hasNewPost: false,
|
|
68
|
+
triggered: false,
|
|
69
|
+
reason: "No snapshot found for date"
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Extract Posts
|
|
75
|
+
// (DataFetcher V2.8 automatically handles the double-encoded JSON)
|
|
76
|
+
const posts = rules.social.extractPosts(todayRow);
|
|
53
77
|
|
|
54
78
|
let hasNewPost = false;
|
|
55
79
|
let latestPost = null;
|
|
56
80
|
|
|
57
|
-
//
|
|
81
|
+
// 4. Check for posts created "Today"
|
|
58
82
|
for (const post of posts) {
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
83
|
+
const postDateObj = rules.social.getPostDate(post);
|
|
84
|
+
if (!postDateObj) continue;
|
|
85
|
+
|
|
86
|
+
// Format YYYY-MM-DD
|
|
87
|
+
const postDateStr = postDateObj.toISOString().slice(0, 10);
|
|
61
88
|
|
|
62
|
-
|
|
63
|
-
if (postDate.toISOString().slice(0, 10) === date) {
|
|
89
|
+
if (postDateStr === date) {
|
|
64
90
|
hasNewPost = true;
|
|
65
91
|
|
|
66
|
-
|
|
67
|
-
if (
|
|
92
|
+
// Track the latest one for the alert title
|
|
93
|
+
if (!latestPost || postDateObj > rules.social.getPostDate(latestPost)) {
|
|
68
94
|
latestPost = post;
|
|
69
95
|
}
|
|
70
96
|
}
|
|
71
97
|
}
|
|
72
98
|
|
|
73
|
-
//
|
|
99
|
+
// 5. Format Title for Alert
|
|
74
100
|
let displayTitle = 'New Update';
|
|
75
101
|
if (latestPost) {
|
|
76
102
|
const text = rules.social.getPostText(latestPost);
|
|
77
|
-
const rawTitle = latestPost.Title;
|
|
103
|
+
const rawTitle = latestPost.Title;
|
|
78
104
|
|
|
79
105
|
if (rawTitle) {
|
|
80
106
|
displayTitle = rawTitle;
|
|
81
107
|
} else if (text) {
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
// Truncate text for display
|
|
109
|
+
displayTitle = text.substring(0, 60).replace(/\n/g, ' ');
|
|
110
|
+
if (text.length > 60) displayTitle += '...';
|
|
84
111
|
}
|
|
85
112
|
}
|
|
86
113
|
|
|
114
|
+
// 6. Set Result
|
|
87
115
|
const result = {
|
|
88
116
|
hasNewPost,
|
|
89
117
|
latestPostDate: latestPost ? rules.social.getPostDate(latestPost) : null,
|
|
90
118
|
postCount: posts.length,
|
|
91
119
|
title: displayTitle,
|
|
92
|
-
triggered: hasNewPost
|
|
120
|
+
triggered: hasNewPost // Only triggers if a post matched today's date
|
|
93
121
|
};
|
|
94
122
|
|
|
95
123
|
this.setResult(entityId, result);
|