bulltrackers-module 1.0.913 → 1.0.915
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/api-v3/middleware/identity.js +1 -0
- package/functions/api-v3/routes/dashboard.js +276 -0
- package/functions/api-v3/routes/index.js +2 -0
- package/functions/computation-system-v3/computations/DashboardPage.js +224 -0
- package/functions/computation-system-v3/config/bulltrackers.config.js +2 -1
- package/functions/computation-system-v3/framework/lib/dashboardParsing.js +72 -0
- package/package.json +1 -1
|
@@ -39,6 +39,7 @@ function createIdentityMiddleware() {
|
|
|
39
39
|
if (/^\/alerts\b/.test(p)) return true;
|
|
40
40
|
// User settings/profile
|
|
41
41
|
if (/^\/profile\b/.test(p)) return true;
|
|
42
|
+
if (/^\/dashboard\b/.test(p)) return true;
|
|
42
43
|
if (/^\/settings\b/.test(p)) return true;
|
|
43
44
|
return false;
|
|
44
45
|
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dashboard Routes
|
|
3
|
+
* Serves dashboard page data: global computation result (DashboardPage) plus
|
|
4
|
+
* signed-in user's alerts from Firestore. Frontend uses toggle for "my alerts" vs "all" (recentAlertEvents).
|
|
5
|
+
* Also handles on-demand sync to re-run stale computations (> 2 days old).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { createRoute, createRouter, z } = require('../core/route-factory');
|
|
9
|
+
const { requireTargetUser } = require('../middleware/identity');
|
|
10
|
+
|
|
11
|
+
const today = () => new Date().toISOString().split('T')[0];
|
|
12
|
+
const toDayString = (d) => d.toISOString().split('T')[0];
|
|
13
|
+
const DASHBOARD_COMPUTATION_NAME = 'dashboardpage';
|
|
14
|
+
const DEFAULT_LOOKBACK_DAYS = 7;
|
|
15
|
+
const USER_ALERTS_LIMIT = 100;
|
|
16
|
+
const STALE_DAYS_THRESHOLD = 2;
|
|
17
|
+
const SYNC_JOB_TIMEOUT_MINS = 30;
|
|
18
|
+
|
|
19
|
+
const calculateDaysOld = (computationDate) => {
|
|
20
|
+
if (!computationDate) return null;
|
|
21
|
+
const compDate = new Date(`${computationDate}T00:00:00Z`);
|
|
22
|
+
const todayDate = new Date();
|
|
23
|
+
// Normalize today to UTC midnight
|
|
24
|
+
const todayUTC = new Date(Date.UTC(todayDate.getUTCFullYear(), todayDate.getUTCMonth(), todayDate.getUTCDate()));
|
|
25
|
+
const diffMs = todayUTC.getTime() - compDate.getTime();
|
|
26
|
+
const daysOld = Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
|
27
|
+
return Math.max(0, daysOld);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getSyncJobPath = (userId, jobId) => {
|
|
31
|
+
return `SyncJobs/${userId}/dashboardpage/${jobId}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const emptyDashboardData = () => ({
|
|
36
|
+
topPIRiskChange7d: [],
|
|
37
|
+
mostPurchasedAssets: [],
|
|
38
|
+
mostSoldAssets: [],
|
|
39
|
+
mostOwnedAssetsWithAum: [],
|
|
40
|
+
totalAum: 0,
|
|
41
|
+
recentAlertEvents: []
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const routes = [
|
|
45
|
+
createRoute({
|
|
46
|
+
method: 'GET',
|
|
47
|
+
path: '/',
|
|
48
|
+
summary: 'Get dashboard data for the signed-in user',
|
|
49
|
+
tags: ['Dashboard'],
|
|
50
|
+
middleware: [requireTargetUser],
|
|
51
|
+
validation: {
|
|
52
|
+
query: z.object({
|
|
53
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().default(today),
|
|
54
|
+
lookback: z.union([z.string().regex(/^\d+$/).transform(Number), z.number()])
|
|
55
|
+
.pipe(z.number().min(1).max(30)).optional().default(DEFAULT_LOOKBACK_DAYS)
|
|
56
|
+
}),
|
|
57
|
+
context: z.object({
|
|
58
|
+
targetUserId: z.string().describe('The signed-in user ID')
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
handler: async (req, res, next) => {
|
|
62
|
+
try {
|
|
63
|
+
const { computationReader, userDataService } = req.services;
|
|
64
|
+
const { date, lookback } = req.validated.query;
|
|
65
|
+
const { targetUserId } = req.validated.context;
|
|
66
|
+
|
|
67
|
+
const [lookbackResult, userAlerts] = await Promise.all([
|
|
68
|
+
computationReader.getResultWithLookback(DASHBOARD_COMPUTATION_NAME, date, null, lookback),
|
|
69
|
+
userDataService.getUserAlerts(targetUserId, { limit: USER_ALERTS_LIMIT })
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const computationDate = lookbackResult?.date || null;
|
|
73
|
+
const daysOld = calculateDaysOld(computationDate);
|
|
74
|
+
const canSync = daysOld !== null && daysOld >= STALE_DAYS_THRESHOLD;
|
|
75
|
+
|
|
76
|
+
const resultPayload = lookbackResult?.data?.result ?? lookbackResult?.data ?? null;
|
|
77
|
+
const data = resultPayload && typeof resultPayload === 'object'
|
|
78
|
+
? {
|
|
79
|
+
topPIRiskChange7d: resultPayload.topPIRiskChange7d ?? [],
|
|
80
|
+
mostPurchasedAssets: resultPayload.mostPurchasedAssets ?? [],
|
|
81
|
+
mostSoldAssets: resultPayload.mostSoldAssets ?? [],
|
|
82
|
+
mostOwnedAssetsWithAum: resultPayload.mostOwnedAssetsWithAum ?? [],
|
|
83
|
+
totalAum: resultPayload.totalAum ?? 0,
|
|
84
|
+
recentAlertEvents: resultPayload.recentAlertEvents ?? []
|
|
85
|
+
}
|
|
86
|
+
: emptyDashboardData();
|
|
87
|
+
|
|
88
|
+
const userAlertCount = userAlerts.filter(a => a.read !== true).length;
|
|
89
|
+
|
|
90
|
+
res.json({
|
|
91
|
+
success: true,
|
|
92
|
+
computationDate,
|
|
93
|
+
daysOld,
|
|
94
|
+
canSync,
|
|
95
|
+
data,
|
|
96
|
+
userAlerts,
|
|
97
|
+
userAlertCount
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
next(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}),
|
|
104
|
+
// POST /sync - Request on-demand sync for stale computation
|
|
105
|
+
createRoute({
|
|
106
|
+
method: 'POST',
|
|
107
|
+
path: '/sync',
|
|
108
|
+
summary: 'Request on-demand sync for dashboard computation if data is stale (> 2 days)',
|
|
109
|
+
tags: ['Dashboard'],
|
|
110
|
+
middleware: [requireTargetUser],
|
|
111
|
+
validation: {
|
|
112
|
+
context: z.object({
|
|
113
|
+
targetUserId: z.string().describe('The signed-in user ID')
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
handler: async (req, res, next) => {
|
|
117
|
+
try {
|
|
118
|
+
const { computationReader, computationSystemClient, db } = req.services;
|
|
119
|
+
const { targetUserId } = req.validated.context;
|
|
120
|
+
|
|
121
|
+
// Check current computation date
|
|
122
|
+
const lookbackResult = await computationReader.getResultWithLookback(
|
|
123
|
+
DASHBOARD_COMPUTATION_NAME,
|
|
124
|
+
today(),
|
|
125
|
+
null,
|
|
126
|
+
DEFAULT_LOOKBACK_DAYS
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const computationDate = lookbackResult?.date || null;
|
|
130
|
+
const daysOld = calculateDaysOld(computationDate);
|
|
131
|
+
|
|
132
|
+
// Check if stale
|
|
133
|
+
if (daysOld === null || daysOld < STALE_DAYS_THRESHOLD) {
|
|
134
|
+
return res.status(400).json({
|
|
135
|
+
success: false,
|
|
136
|
+
error: 'Data is not stale enough for sync',
|
|
137
|
+
daysOld,
|
|
138
|
+
staleDaysRequired: STALE_DAYS_THRESHOLD,
|
|
139
|
+
computationDate
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Enqueue on-demand computation request
|
|
144
|
+
const targetDate = today();
|
|
145
|
+
const jobId = `sync-dashboardpage-${targetUserId}-${Date.now()}`;
|
|
146
|
+
|
|
147
|
+
const triggerResult = await computationSystemClient.triggerOnDemandRequest({
|
|
148
|
+
computation: DASHBOARD_COMPUTATION_NAME,
|
|
149
|
+
targetDate,
|
|
150
|
+
entityIds: ['_global'],
|
|
151
|
+
allowToday: true,
|
|
152
|
+
requestKind: 'user_sync_request',
|
|
153
|
+
metadata: {
|
|
154
|
+
userId: targetUserId,
|
|
155
|
+
syncJobId: jobId,
|
|
156
|
+
userType: 'SIGNED_IN_USER'
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!triggerResult.success) {
|
|
161
|
+
return res.status(500).json({
|
|
162
|
+
success: false,
|
|
163
|
+
error: 'Failed to enqueue computation task',
|
|
164
|
+
detail: triggerResult.error
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Write job status to Firestore
|
|
169
|
+
const jobRef = db.doc(getSyncJobPath(targetUserId, jobId));
|
|
170
|
+
await jobRef.set({
|
|
171
|
+
status: 'queued',
|
|
172
|
+
createdAt: new Date(),
|
|
173
|
+
computationName: DASHBOARD_COMPUTATION_NAME,
|
|
174
|
+
targetDate,
|
|
175
|
+
daysOld,
|
|
176
|
+
userId: targetUserId
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
res.json({
|
|
180
|
+
success: true,
|
|
181
|
+
jobId,
|
|
182
|
+
status: 'queued',
|
|
183
|
+
eta: '5-10 minutes',
|
|
184
|
+
computationDate
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
next(error);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}),
|
|
191
|
+
// GET /sync/status/:jobId - Poll sync job status
|
|
192
|
+
createRoute({
|
|
193
|
+
method: 'GET',
|
|
194
|
+
path: '/sync/status/:jobId',
|
|
195
|
+
summary: 'Check status of a dashboard sync job',
|
|
196
|
+
tags: ['Dashboard'],
|
|
197
|
+
middleware: [requireTargetUser],
|
|
198
|
+
validation: {
|
|
199
|
+
params: z.object({
|
|
200
|
+
jobId: z.string().describe('The sync job ID')
|
|
201
|
+
}),
|
|
202
|
+
context: z.object({
|
|
203
|
+
targetUserId: z.string().describe('The signed-in user ID')
|
|
204
|
+
})
|
|
205
|
+
},
|
|
206
|
+
handler: async (req, res, next) => {
|
|
207
|
+
try {
|
|
208
|
+
const { computationReader, db } = req.services;
|
|
209
|
+
const { jobId } = req.validated.params;
|
|
210
|
+
const { targetUserId } = req.validated.context;
|
|
211
|
+
|
|
212
|
+
// Read job status from Firestore
|
|
213
|
+
const jobRef = db.doc(getSyncJobPath(targetUserId, jobId));
|
|
214
|
+
const jobDoc = await jobRef.get();
|
|
215
|
+
|
|
216
|
+
if (!jobDoc.exists) {
|
|
217
|
+
return res.status(404).json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: 'Sync job not found'
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const jobData = jobDoc.data();
|
|
224
|
+
const createdAt = jobData.createdAt?.toDate ? jobData.createdAt.toDate() : new Date(jobData.createdAt);
|
|
225
|
+
const elapsedMs = Date.now() - createdAt.getTime();
|
|
226
|
+
const timedOut = elapsedMs > SYNC_JOB_TIMEOUT_MINS * 60 * 1000;
|
|
227
|
+
|
|
228
|
+
if (timedOut && (jobData.status === 'queued' || jobData.status === 'running')) {
|
|
229
|
+
// Mark as failed due to timeout
|
|
230
|
+
await jobRef.update({ status: 'failed', error: 'Computation timeout' });
|
|
231
|
+
return res.json({
|
|
232
|
+
success: true,
|
|
233
|
+
jobId,
|
|
234
|
+
status: 'failed',
|
|
235
|
+
error: 'Computation timeout (exceeded 30 minutes)'
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check if newer result exists in BQ
|
|
240
|
+
if (jobData.status !== 'completed') {
|
|
241
|
+
const latestResult = await computationReader.getResultWithLookback(
|
|
242
|
+
DASHBOARD_COMPUTATION_NAME,
|
|
243
|
+
today(),
|
|
244
|
+
null,
|
|
245
|
+
1 // Just check today's result
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// If result date is newer than job creation, computation succeeded
|
|
249
|
+
if (latestResult?.date && new Date(`${latestResult.date}T00:00:00Z`).getTime() >= createdAt.getTime()) {
|
|
250
|
+
// Mark as completed
|
|
251
|
+
await jobRef.update({ status: 'completed', resultDate: latestResult.date });
|
|
252
|
+
return res.json({
|
|
253
|
+
success: true,
|
|
254
|
+
jobId,
|
|
255
|
+
status: 'completed',
|
|
256
|
+
resultDate: latestResult.date
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Still running or already completed
|
|
262
|
+
res.json({
|
|
263
|
+
success: true,
|
|
264
|
+
jobId,
|
|
265
|
+
status: jobData.status || 'running',
|
|
266
|
+
eta: jobData.status === 'completed' ? null : `${Math.ceil((30 * 60 * 1000 - elapsedMs) / 60000)} minutes remaining`,
|
|
267
|
+
resultDate: jobData.resultDate || null
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
next(error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
module.exports = createRouter(routes);
|
|
@@ -21,6 +21,7 @@ const createComputationsRouter = require('./computations');
|
|
|
21
21
|
const createWorkspaceRouter = require('./workspace');
|
|
22
22
|
const runsRoutes = require('./runs');
|
|
23
23
|
const logsRoutes = require('./logs');
|
|
24
|
+
const dashboardRoutes = require('./dashboard');
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Create and configure all API routes.
|
|
@@ -43,6 +44,7 @@ function createRoutes() {
|
|
|
43
44
|
router.use('/billing', billingRoutes);
|
|
44
45
|
router.use('/computations', createComputationsRouter());
|
|
45
46
|
router.use('/workspace', createWorkspaceRouter());
|
|
47
|
+
router.use('/dashboard', dashboardRoutes);
|
|
46
48
|
router.use('/runs', runsRoutes);
|
|
47
49
|
router.use('/logs', logsRoutes);
|
|
48
50
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dashboard Page Computation (V3)
|
|
3
|
+
* Global aggregation: top PI risk change (7d), most purchased/sold assets,
|
|
4
|
+
* most commonly owned assets with AUM, total AUM, and recent PI alert events.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../framework/core/ContextBuilder').ComputationContext} ComputationContext */
|
|
8
|
+
|
|
9
|
+
const TOP_RISK_CHANGE_N = 20;
|
|
10
|
+
const TOP_ASSETS_N = 30;
|
|
11
|
+
const RECENT_ALERTS_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
exports.config = {
|
|
14
|
+
name: 'DashboardPage',
|
|
15
|
+
type: 'global',
|
|
16
|
+
category: 'market_insights',
|
|
17
|
+
skills: ['lib', 'bigquery'],
|
|
18
|
+
|
|
19
|
+
requires: {
|
|
20
|
+
'pi_rankings': {
|
|
21
|
+
lookback: 7,
|
|
22
|
+
mandatory: true,
|
|
23
|
+
fields: ['pi_id', 'username', 'rankings_data', 'date']
|
|
24
|
+
},
|
|
25
|
+
'trade_history_snapshots': {
|
|
26
|
+
lookback: 7,
|
|
27
|
+
mandatory: true,
|
|
28
|
+
fields: ['user_id', 'history_data', 'date'],
|
|
29
|
+
filter: { user_type: 'POPULAR_INVESTOR' }
|
|
30
|
+
},
|
|
31
|
+
'portfolio_snapshots': {
|
|
32
|
+
lookback: 0,
|
|
33
|
+
mandatory: false,
|
|
34
|
+
fields: ['user_id', 'portfolio_data', 'date'],
|
|
35
|
+
filter: { user_type: 'POPULAR_INVESTOR' }
|
|
36
|
+
},
|
|
37
|
+
'pi_alert_history': {
|
|
38
|
+
lookback: 7,
|
|
39
|
+
mandatory: false,
|
|
40
|
+
fields: ['date', 'pi_id', 'alert_type', 'triggered', 'trigger_count', 'last_triggered', 'last_updated']
|
|
41
|
+
},
|
|
42
|
+
'ticker_mappings': {
|
|
43
|
+
lookback: 0,
|
|
44
|
+
mandatory: false,
|
|
45
|
+
fields: ['instrument_id', 'ticker']
|
|
46
|
+
},
|
|
47
|
+
'results': {
|
|
48
|
+
lookback: 0,
|
|
49
|
+
mandatory: false,
|
|
50
|
+
fields: ['result_data', 'computation_name', 'date', 'entity_id'],
|
|
51
|
+
filter: { computation_name: 'globalaumperasset30d' }
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
dependencies: ['globalaumperasset30d'],
|
|
56
|
+
|
|
57
|
+
storage: {
|
|
58
|
+
bigquery: true
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {ComputationContext} ctx
|
|
64
|
+
*/
|
|
65
|
+
exports.process = (ctx) => {
|
|
66
|
+
const { data, date, lib } = ctx;
|
|
67
|
+
|
|
68
|
+
const toArray = (input) => {
|
|
69
|
+
if (!input) return [];
|
|
70
|
+
if (Array.isArray(input)) return input;
|
|
71
|
+
return Object.values(input).flat();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const rankings = toArray(data['pi_rankings'] || []);
|
|
75
|
+
const tradeHistory = toArray(data['trade_history_snapshots'] || []);
|
|
76
|
+
const alertHistory = toArray(data['pi_alert_history'] || []);
|
|
77
|
+
const tickerRows = toArray(data['ticker_mappings'] || []);
|
|
78
|
+
|
|
79
|
+
const tickerMap = new Map();
|
|
80
|
+
tickerRows.forEach(row => {
|
|
81
|
+
if (row.instrument_id != null && row.ticker) {
|
|
82
|
+
tickerMap.set(Number(row.instrument_id), String(row.ticker).toUpperCase());
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const resolveTicker = (instrumentId) => tickerMap.get(Number(instrumentId)) || `ID:${instrumentId}`;
|
|
86
|
+
|
|
87
|
+
const usernameByPiId = new Map();
|
|
88
|
+
rankings.forEach(r => {
|
|
89
|
+
const id = r.pi_id != null ? String(r.pi_id) : null;
|
|
90
|
+
if (id) usernameByPiId.set(id, r.username || r.UserName || null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
// 1. Top PI risk change (7d)
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
const riskByPi = new Map();
|
|
97
|
+
rankings.forEach(row => {
|
|
98
|
+
const piId = row.pi_id != null ? String(row.pi_id) : null;
|
|
99
|
+
if (!piId) return;
|
|
100
|
+
const rData = lib.rankings.extractRankingsData(row);
|
|
101
|
+
const risk = rData != null ? lib.rankings.getRiskScore(rData) : 0;
|
|
102
|
+
const dateStr = row.date && (typeof row.date.value === 'string' ? row.date.value : String(row.date).slice(0, 10));
|
|
103
|
+
if (!dateStr) return;
|
|
104
|
+
if (!riskByPi.has(piId)) riskByPi.set(piId, []);
|
|
105
|
+
riskByPi.get(piId).push({ date: dateStr, risk });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const topPIRiskChange7d = [];
|
|
109
|
+
riskByPi.forEach((points, piId) => {
|
|
110
|
+
if (points.length < 2) return;
|
|
111
|
+
points.sort((a, b) => a.date.localeCompare(b.date));
|
|
112
|
+
const first = points[0].risk;
|
|
113
|
+
const last = points[points.length - 1].risk;
|
|
114
|
+
const change = last - first;
|
|
115
|
+
topPIRiskChange7d.push({
|
|
116
|
+
piId,
|
|
117
|
+
username: usernameByPiId.get(piId) || 'Unknown',
|
|
118
|
+
change: Number(change.toFixed(2))
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
topPIRiskChange7d.sort((a, b) => Math.abs(b.change) - Math.abs(a.change));
|
|
122
|
+
const topPIRiskChange7dSlice = topPIRiskChange7d.slice(0, TOP_RISK_CHANGE_N);
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
// 2. Most purchased / most sold assets (from trade_history_snapshots)
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
const purchaseCount = new Map();
|
|
128
|
+
const saleCount = new Map();
|
|
129
|
+
|
|
130
|
+
tradeHistory.forEach(row => {
|
|
131
|
+
const trades = lib.trades.extractTrades(row);
|
|
132
|
+
trades.forEach(trade => {
|
|
133
|
+
const instrumentId = lib.trades.getInstrumentId(trade);
|
|
134
|
+
if (instrumentId == null) return;
|
|
135
|
+
const ticker = resolveTicker(instrumentId);
|
|
136
|
+
if (lib.trades.isBuy(trade)) {
|
|
137
|
+
purchaseCount.set(ticker, (purchaseCount.get(ticker) || 0) + 1);
|
|
138
|
+
} else {
|
|
139
|
+
saleCount.set(ticker, (saleCount.get(ticker) || 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const mostPurchasedAssets = Array.from(purchaseCount.entries())
|
|
145
|
+
.map(([ticker, count]) => ({ ticker, count }))
|
|
146
|
+
.sort((a, b) => b.count - a.count)
|
|
147
|
+
.slice(0, TOP_ASSETS_N);
|
|
148
|
+
|
|
149
|
+
const mostSoldAssets = Array.from(saleCount.entries())
|
|
150
|
+
.map(([ticker, count]) => ({ ticker, count }))
|
|
151
|
+
.sort((a, b) => b.count - a.count)
|
|
152
|
+
.slice(0, TOP_ASSETS_N);
|
|
153
|
+
|
|
154
|
+
// -------------------------------------------------------------------------
|
|
155
|
+
// 3. Most commonly owned assets + AUM (from GlobalAumPerAsset30D result)
|
|
156
|
+
// -------------------------------------------------------------------------
|
|
157
|
+
let mostOwnedAssetsWithAum = [];
|
|
158
|
+
const resultsRows = toArray(data['results'] || []);
|
|
159
|
+
for (const row of resultsRows) {
|
|
160
|
+
if (row.entity_id !== '_global') continue;
|
|
161
|
+
let resultData = row.result_data;
|
|
162
|
+
if (typeof resultData === 'string') {
|
|
163
|
+
try { resultData = JSON.parse(resultData); } catch (_) { continue; }
|
|
164
|
+
}
|
|
165
|
+
const arr = Array.isArray(resultData) ? resultData : (resultData && resultData.result);
|
|
166
|
+
if (Array.isArray(arr)) {
|
|
167
|
+
mostOwnedAssetsWithAum = arr
|
|
168
|
+
.filter(x => x && (x.symbol || x.ticker) && (x.amount != null))
|
|
169
|
+
.map(x => ({ symbol: x.symbol || x.ticker, amount: Number(x.amount) }));
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
// 4. Total AUM (latest date in rankings window)
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
const dates = [...new Set(rankings.map(r => {
|
|
178
|
+
const d = r.date;
|
|
179
|
+
return d && (typeof d.value === 'string' ? d.value : String(d).slice(0, 10));
|
|
180
|
+
}).filter(Boolean))].sort();
|
|
181
|
+
const latestDate = dates.length ? dates[dates.length - 1] : date;
|
|
182
|
+
|
|
183
|
+
let totalAum = 0;
|
|
184
|
+
rankings.forEach(row => {
|
|
185
|
+
const d = row.date;
|
|
186
|
+
const dateStr = d && (typeof d.value === 'string' ? d.value : String(d).slice(0, 10));
|
|
187
|
+
if (dateStr !== latestDate) return;
|
|
188
|
+
const rData = lib.rankings.extractRankingsData(row);
|
|
189
|
+
if (rData) totalAum += lib.rankings.getAUM(rData) || 0;
|
|
190
|
+
});
|
|
191
|
+
totalAum = Number(totalAum.toFixed(2));
|
|
192
|
+
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
// 5. Recent alert events (all) — normalized for API
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
const recentAlertEvents = lib.dashboardParsing
|
|
197
|
+
? lib.dashboardParsing.normalizeRecentAlertEvents(alertHistory, RECENT_ALERTS_LIMIT)
|
|
198
|
+
: alertHistory.slice(0, RECENT_ALERTS_LIMIT).map(row => ({
|
|
199
|
+
date: row.date && (typeof row.date.value === 'string' ? row.date.value : String(row.date).slice(0, 10)),
|
|
200
|
+
pi_id: row.pi_id != null ? Number(row.pi_id) : null,
|
|
201
|
+
alert_type: row.alert_type || null,
|
|
202
|
+
trigger_count: row.trigger_count != null ? Number(row.trigger_count) : null,
|
|
203
|
+
last_triggered: row.last_triggered
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// Attach username to each alert event when available
|
|
207
|
+
recentAlertEvents.forEach(ev => {
|
|
208
|
+
if (ev.pi_id != null && usernameByPiId.has(String(ev.pi_id))) {
|
|
209
|
+
ev.username = usernameByPiId.get(String(ev.pi_id));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
outcome: 'completed',
|
|
215
|
+
result: {
|
|
216
|
+
topPIRiskChange7d: topPIRiskChange7dSlice,
|
|
217
|
+
mostPurchasedAssets,
|
|
218
|
+
mostSoldAssets,
|
|
219
|
+
mostOwnedAssetsWithAum,
|
|
220
|
+
totalAum,
|
|
221
|
+
recentAlertEvents
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dashboard parsing and normalization helpers.
|
|
3
|
+
* Normalizes pi_alert_history rows and optional trade/portfolio aggregates
|
|
4
|
+
* for the DashboardPage computation and consistent API payload shape.
|
|
5
|
+
* Factory pattern: module.exports = () => ({ ... }) for ctx.lib.dashboardParsing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
module.exports = () => {
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a single pi_alert_history row for dashboard display.
|
|
11
|
+
* @param {Object} row - Raw row from BigQuery (date, pi_id, alert_type, trigger_count, last_triggered, etc.)
|
|
12
|
+
* @returns {Object} Normalized shape: { date, pi_id, alert_type, trigger_count, last_triggered }
|
|
13
|
+
*/
|
|
14
|
+
function normalizeAlertEvent(row) {
|
|
15
|
+
if (!row) return null;
|
|
16
|
+
const toDateStr = (v) => {
|
|
17
|
+
if (!v) return null;
|
|
18
|
+
if (typeof v === 'string') return v.slice(0, 10);
|
|
19
|
+
if (v.value) return String(v.value).slice(0, 10);
|
|
20
|
+
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
|
21
|
+
if (typeof v.toDate === 'function') return v.toDate().toISOString().slice(0, 10);
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const toIso = (v) => {
|
|
25
|
+
if (!v) return null;
|
|
26
|
+
if (typeof v === 'string') return v;
|
|
27
|
+
if (v instanceof Date) return v.toISOString();
|
|
28
|
+
if (typeof v.toDate === 'function') return v.toDate().toISOString();
|
|
29
|
+
if (typeof v === 'object' && typeof v._seconds === 'number') {
|
|
30
|
+
const ms = v._seconds * 1000 + Math.floor((v._nanoseconds || 0) / 1e6);
|
|
31
|
+
return new Date(ms).toISOString();
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
date: toDateStr(row.date),
|
|
37
|
+
pi_id: row.pi_id != null ? Number(row.pi_id) : null,
|
|
38
|
+
alert_type: row.alert_type || null,
|
|
39
|
+
triggered: row.triggered,
|
|
40
|
+
trigger_count: row.trigger_count != null ? Number(row.trigger_count) : null,
|
|
41
|
+
last_triggered: toIso(row.last_triggered),
|
|
42
|
+
last_updated: toIso(row.last_updated)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize an array of pi_alert_history rows and sort by last_triggered desc.
|
|
48
|
+
* @param {Array|Object} rows - Rows from framework data (array or entity-keyed object)
|
|
49
|
+
* @param {number} limit - Max items to return
|
|
50
|
+
* @returns {Array<Object>} Normalized array, most recent first
|
|
51
|
+
*/
|
|
52
|
+
function normalizeRecentAlertEvents(rows, limit = 50) {
|
|
53
|
+
const toArray = (input) => {
|
|
54
|
+
if (!input) return [];
|
|
55
|
+
if (Array.isArray(input)) return input;
|
|
56
|
+
return Object.values(input).flat();
|
|
57
|
+
};
|
|
58
|
+
const arr = toArray(rows);
|
|
59
|
+
const normalized = arr.map(normalizeAlertEvent).filter(Boolean);
|
|
60
|
+
const withTimestamp = normalized.map((n) => ({
|
|
61
|
+
...n,
|
|
62
|
+
_sort: n.last_triggered ? new Date(n.last_triggered).getTime() : 0
|
|
63
|
+
}));
|
|
64
|
+
withTimestamp.sort((a, b) => b._sort - a._sort);
|
|
65
|
+
return withTimestamp.slice(0, limit).map(({ _sort, ...rest }) => rest);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
normalizeAlertEvent,
|
|
70
|
+
normalizeRecentAlertEvents
|
|
71
|
+
};
|
|
72
|
+
};
|