bulltrackers-module 1.0.994 → 1.0.996
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/routes/popular-investors.js +4 -3
- package/functions/api-v3/routes/workspace.js +54 -6
- package/functions/api-v3/services/WriteService.js +10 -0
- package/functions/computation-system-v3/framework/doc-registry.js +7 -0
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade1-portfolio-summary.js +93 -0
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade2-entity-risk-change.js +145 -0
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade3-alert-on-risk-spike.js +102 -0
- package/functions/core/utils/analytics_registry.js +116 -0
- package/functions/maintenance/analytics-load-firestore-to-bq/ANALYTICS_SYSTEM_DOCUMENTATION.md +49 -7
- package/functions/maintenance/analytics-load-firestore-to-bq/README.md +6 -0
- package/functions/maintenance/analytics-load-firestore-to-bq/index.js +55 -215
- package/functions/maintenance/analytics-load-firestore-to-bq/sourceConfig.js +268 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ const { requireFirebaseAuth } = require('../middleware/auth');
|
|
|
9
9
|
const { requireVerifiedUser, sanitizeCid } = require('../middleware/identity');
|
|
10
10
|
|
|
11
11
|
const { EtoroApiService } = require('../services/EtoroApiService');
|
|
12
|
+
const { recordAnalyticsWrite } = require('../../core/utils/analytics_registry');
|
|
12
13
|
|
|
13
14
|
const router = express.Router();
|
|
14
15
|
|
|
@@ -137,7 +138,7 @@ router.get('/search', async (req, res, next) => {
|
|
|
137
138
|
query: validated.query,
|
|
138
139
|
resultCount: results.length,
|
|
139
140
|
createdAt: new Date()
|
|
140
|
-
}).catch(() => {});
|
|
141
|
+
}).then(() => recordAnalyticsWrite(db, 'analytics_events_search_query').catch(() => {})).catch(() => {});
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
res.json({ success: true, count: results.length, data: results });
|
|
@@ -295,7 +296,7 @@ router.post('/request-addition', async (req, res, next) => {
|
|
|
295
296
|
requestedAt: new Date(),
|
|
296
297
|
status: 'rejected_not_pi',
|
|
297
298
|
etoroCid: info.realCID || info.gcid || null
|
|
298
|
-
}).catch(() => {});
|
|
299
|
+
}).then(() => recordAnalyticsWrite(db, 'pi_addition_requests').catch(() => {})).catch(() => {});
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
return res.status(400).json({
|
|
@@ -355,7 +356,7 @@ router.post('/request-addition', async (req, res, next) => {
|
|
|
355
356
|
requestedAt: new Date(),
|
|
356
357
|
status: 'accepted',
|
|
357
358
|
isPi: true
|
|
358
|
-
}).catch(() => {});
|
|
359
|
+
}).then(() => recordAnalyticsWrite(db, 'pi_addition_requests').catch(() => {})).catch(() => {});
|
|
359
360
|
}
|
|
360
361
|
|
|
361
362
|
return res.json({
|
|
@@ -4,11 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const express = require('express');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
7
9
|
const config = require('../config');
|
|
8
10
|
const { requireVerifiedUser } = require('../middleware/identity');
|
|
9
11
|
const { requireFirebaseAuth } = require('../middleware/auth');
|
|
10
12
|
const { generateDynamicPaths } = require('./workspace-generator');
|
|
11
13
|
|
|
14
|
+
const FRAMEWORK_ROOT = path.join(__dirname, '../../computation-system-v3/framework');
|
|
15
|
+
|
|
16
|
+
// Read-only sample computation templates that we copy into each user's
|
|
17
|
+
// editable `computations/` directory on first bootstrap.
|
|
18
|
+
const SAMPLE_COMPUTATION_TEMPLATES = [
|
|
19
|
+
{
|
|
20
|
+
source: 'docs/authoring/sample-computations/grade1-portfolio-summary.js',
|
|
21
|
+
target: 'computations/Sample_PortfolioDailySummary_Grade1.js'
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
source: 'docs/authoring/sample-computations/grade2-entity-risk-change.js',
|
|
25
|
+
target: 'computations/Sample_EntityRiskChange_Grade2.js'
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
source: 'docs/authoring/sample-computations/grade3-alert-on-risk-spike.js',
|
|
29
|
+
target: 'computations/Sample_RiskSpikeAlerts_Grade3.js'
|
|
30
|
+
}
|
|
31
|
+
];
|
|
32
|
+
|
|
12
33
|
const MAX_PATHS_PER_USER = 500;
|
|
13
34
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
14
35
|
const MAX_TOTAL_BYTES = 5 * 1024 * 1024;
|
|
@@ -147,21 +168,48 @@ function createWorkspaceRouter() {
|
|
|
147
168
|
}
|
|
148
169
|
});
|
|
149
170
|
|
|
150
|
-
// POST /workspace/bootstrap —
|
|
171
|
+
// POST /workspace/bootstrap — Initializes user directories and sample computations.
|
|
151
172
|
router.post('/bootstrap', requireVerifiedUser, async (req, res, next) => {
|
|
152
173
|
try {
|
|
153
174
|
const userId = (req.identity && req.identity.cid) || req.targetUserId;
|
|
154
175
|
const ref = req.services.db.collection('users').doc(userId).collection('workspace_files');
|
|
155
176
|
const snap = await ref.get();
|
|
156
177
|
|
|
157
|
-
// We only need to bootstrap user-editable files now.
|
|
178
|
+
// We only need to bootstrap user-editable files now.
|
|
158
179
|
// The read-only registry is automatically served in /tree and /file.
|
|
159
|
-
const userPaths = [
|
|
160
|
-
|
|
161
|
-
|
|
180
|
+
const userPaths = [{ path: 'computations', kind: 'directory', content: null }];
|
|
181
|
+
|
|
182
|
+
// Pre-populate the computations/ directory with three fully documented,
|
|
183
|
+
// valid sample computations. The source of truth lives under
|
|
184
|
+
// computation-system-v3/framework/docs/authoring/sample-computations/*.
|
|
185
|
+
const sampleFiles = [];
|
|
186
|
+
for (const template of SAMPLE_COMPUTATION_TEMPLATES) {
|
|
187
|
+
const absolute = path.join(FRAMEWORK_ROOT, template.source);
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync(absolute)) {
|
|
190
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
191
|
+
sampleFiles.push({
|
|
192
|
+
path: template.target,
|
|
193
|
+
kind: 'file',
|
|
194
|
+
content
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
// If a template cannot be read, skip it; bootstrap should still succeed.
|
|
199
|
+
// The admin can fix the underlying file and re-bootstrap later.
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.error(
|
|
202
|
+
'[workspace.bootstrap] Failed to read sample computation template',
|
|
203
|
+
template.source,
|
|
204
|
+
e && e.message
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
162
208
|
|
|
163
209
|
const now = new Date().toISOString();
|
|
164
|
-
|
|
210
|
+
const bootstrapItems = [...userPaths, ...sampleFiles];
|
|
211
|
+
|
|
212
|
+
for (const item of bootstrapItems) {
|
|
165
213
|
const existing = snap.docs.find((d) => (d.data().path || d.id.replace(/__/g, '/')) === item.path);
|
|
166
214
|
if (existing) continue;
|
|
167
215
|
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
} = require('../../shared/upstashRedis');
|
|
12
12
|
|
|
13
13
|
const { VALID_WATCHLIST_TAGS } = require('../constants');
|
|
14
|
+
const { recordAnalyticsWrite } = require('../../core/utils/analytics_registry');
|
|
14
15
|
|
|
15
16
|
const LIMITS = {
|
|
16
17
|
WATCHLIST_NAME_MAX: 200,
|
|
@@ -349,6 +350,7 @@ class WriteService {
|
|
|
349
350
|
|
|
350
351
|
await this._updateWatchlistMembership(validUserId, watchlistId, oldItems, firestoreData.items || []);
|
|
351
352
|
|
|
353
|
+
recordAnalyticsWrite(this.db, 'watchlists').catch(() => {});
|
|
352
354
|
firestoreSuccess = true;
|
|
353
355
|
} catch (error) {
|
|
354
356
|
console.error('[WriteService] Firestore write failed for watchlist:', error.message);
|
|
@@ -381,6 +383,7 @@ class WriteService {
|
|
|
381
383
|
// Delete from Firestore only. Hourly analytics-load job syncs watchlist state to BigQuery.
|
|
382
384
|
await docRef.delete();
|
|
383
385
|
|
|
386
|
+
recordAnalyticsWrite(this.db, 'watchlists').catch(() => {});
|
|
384
387
|
await this._updateWatchlistMembership(validUserId, validWatchlistId, oldItems, []);
|
|
385
388
|
}
|
|
386
389
|
|
|
@@ -438,6 +441,7 @@ class WriteService {
|
|
|
438
441
|
.doc(validUserId);
|
|
439
442
|
transaction.set(piReviewRef, review);
|
|
440
443
|
});
|
|
444
|
+
recordAnalyticsWrite(this.db, 'reviews').catch(() => {});
|
|
441
445
|
} catch (error) {
|
|
442
446
|
// Rethrow explicit errors, log others
|
|
443
447
|
if (error.message === 'Already reviewed') {
|
|
@@ -520,6 +524,7 @@ class WriteService {
|
|
|
520
524
|
viewedAt: now,
|
|
521
525
|
createdAt: now
|
|
522
526
|
});
|
|
527
|
+
recordAnalyticsWrite(this.db, 'analytics_events_pi_page_view').catch(() => {});
|
|
523
528
|
} catch (error) {
|
|
524
529
|
console.error('[WriteService] analytics_events pi_page_view write failed:', error.message);
|
|
525
530
|
}
|
|
@@ -544,6 +549,7 @@ class WriteService {
|
|
|
544
549
|
new_watchlist_id: this._validateId(newWatchlistId, 'New watchlist ID'),
|
|
545
550
|
createdAt: now
|
|
546
551
|
});
|
|
552
|
+
recordAnalyticsWrite(this.db, 'analytics_events_watchlist_copied').catch(() => {});
|
|
547
553
|
} catch (error) {
|
|
548
554
|
console.error('[WriteService] analytics_events watchlist_copied write failed:', error.message);
|
|
549
555
|
}
|
|
@@ -689,6 +695,7 @@ class WriteService {
|
|
|
689
695
|
...settings,
|
|
690
696
|
updatedAt: new Date()
|
|
691
697
|
}, { merge: true });
|
|
698
|
+
recordAnalyticsWrite(this.db, 'alert_subscriptions').catch(() => {});
|
|
692
699
|
}
|
|
693
700
|
|
|
694
701
|
/**
|
|
@@ -707,6 +714,7 @@ class WriteService {
|
|
|
707
714
|
.collection('alert_subscriptions')
|
|
708
715
|
.doc(validPiCid)
|
|
709
716
|
.delete();
|
|
717
|
+
recordAnalyticsWrite(this.db, 'alert_subscriptions').catch(() => {});
|
|
710
718
|
}
|
|
711
719
|
|
|
712
720
|
// =========================================================================
|
|
@@ -743,6 +751,8 @@ class WriteService {
|
|
|
743
751
|
|
|
744
752
|
await firestoreRef.set({ investors }, { merge: true });
|
|
745
753
|
|
|
754
|
+
recordAnalyticsWrite(this.db, 'pi_master_list').catch(() => {});
|
|
755
|
+
|
|
746
756
|
// Invalidate API cache for PI master list so new PI appears quickly.
|
|
747
757
|
if (this.redisEnabled) {
|
|
748
758
|
const cacheKey = `api:pi-master-list:${coll}:${docId}`;
|
|
@@ -49,6 +49,13 @@ const REGISTERED_FILES = [
|
|
|
49
49
|
'docs/modules/trades.md',
|
|
50
50
|
'docs/modules/userMetrics.md',
|
|
51
51
|
|
|
52
|
+
// ── Authoring Samples ──────────────────────────────────────────────────────────
|
|
53
|
+
// These are read-only, fully documented example computations that are also used
|
|
54
|
+
// as templates for pre-populating the user's `computations/` directory.
|
|
55
|
+
'docs/authoring/sample-computations/grade1-portfolio-summary.js',
|
|
56
|
+
'docs/authoring/sample-computations/grade2-entity-risk-change.js',
|
|
57
|
+
'docs/authoring/sample-computations/grade3-alert-on-risk-spike.js',
|
|
58
|
+
|
|
52
59
|
// ── SQL Reference ─────────────────────────────────────────────────────────────
|
|
53
60
|
'docs/sql/annotated-sql.md',
|
|
54
61
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade 1 sample computation — Daily portfolio value summary.
|
|
3
|
+
*
|
|
4
|
+
* This is a deliberately small, linear example that shows:
|
|
5
|
+
* - How to declare a minimal `config` for a user computation.
|
|
6
|
+
* - How to read from `ctx.data` using the keys defined in `config.requires`.
|
|
7
|
+
* - How to log a short, human-readable trace line via `ctx.log`.
|
|
8
|
+
* - How to return a safe, envelope-compatible result object (no reserved keys).
|
|
9
|
+
*
|
|
10
|
+
* The goal is to be a "perfect role model" for new authors, not to be clever.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Configuration object for the computation.
|
|
15
|
+
*
|
|
16
|
+
* - `name` is globally unique per tenant and used in scheduling / storage paths.
|
|
17
|
+
* - `skills: ['lib']` enables read-only helper libraries on `ctx.lib` (not used here,
|
|
18
|
+
* but included so users see the recommended default).
|
|
19
|
+
* - `requires.portfolio_snapshots` declares the data this computation expects:
|
|
20
|
+
* a recent window of portfolio snapshots with `date` + `total_value` fields.
|
|
21
|
+
* - `storage.bigquery: true` means the framework will persist results to BigQuery
|
|
22
|
+
* under the standard `computations` dataset for this tenant.
|
|
23
|
+
*/
|
|
24
|
+
exports.config = {
|
|
25
|
+
name: 'Sample_PortfolioDailySummary_Grade1',
|
|
26
|
+
skills: ['lib'],
|
|
27
|
+
lib: [],
|
|
28
|
+
requires: {
|
|
29
|
+
portfolio_snapshots: {
|
|
30
|
+
// The runtime will fetch up to 7 days of snapshots ending at ctx.date.
|
|
31
|
+
lookback: 7,
|
|
32
|
+
mandatory: true,
|
|
33
|
+
fields: ['date', 'total_value']
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
storage: {
|
|
37
|
+
bigquery: true
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Process function — called by the runtime for a single execution target.
|
|
43
|
+
*
|
|
44
|
+
* Signature:
|
|
45
|
+
* - ctx.computation: string → the value of config.name.
|
|
46
|
+
* - ctx.date: string → ISO date (YYYY-MM-DD) the run is targeting.
|
|
47
|
+
* - ctx.data: object → data fetched according to `config.requires`.
|
|
48
|
+
* - ctx.log: (message) => void → structured log helper scoped to this computation.
|
|
49
|
+
*
|
|
50
|
+
* Behaviour:
|
|
51
|
+
* 1. Read the `portfolio_snapshots` array from ctx.data.
|
|
52
|
+
* 2. Filter out rows that do not have a finite numeric `total_value`.
|
|
53
|
+
* 3. Compute a simple count and average total value over the window.
|
|
54
|
+
* 4. Log a short summary line with the number of snapshots analysed.
|
|
55
|
+
* 5. Return a result object with:
|
|
56
|
+
* - outcome: high-level status code for domain logic ("OK" or "NO_DATA").
|
|
57
|
+
* - asOfDate: the target date for the run.
|
|
58
|
+
* - snapshotCount: how many rows were actually used.
|
|
59
|
+
* - averagePortfolioValue: null if there was no valid data.
|
|
60
|
+
*/
|
|
61
|
+
exports.process = function process(ctx) {
|
|
62
|
+
const snapshots = Array.isArray(ctx.data && ctx.data.portfolio_snapshots)
|
|
63
|
+
? ctx.data.portfolio_snapshots
|
|
64
|
+
: [];
|
|
65
|
+
|
|
66
|
+
const numericValues = [];
|
|
67
|
+
for (const row of snapshots) {
|
|
68
|
+
const raw = row && row.total_value;
|
|
69
|
+
const value = typeof raw === 'number' ? raw : Number(raw);
|
|
70
|
+
if (Number.isFinite(value)) {
|
|
71
|
+
numericValues.push(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const snapshotCount = numericValues.length;
|
|
76
|
+
const averagePortfolioValue =
|
|
77
|
+
snapshotCount === 0
|
|
78
|
+
? null
|
|
79
|
+
: numericValues.reduce((sum, value) => sum + value, 0) / snapshotCount;
|
|
80
|
+
|
|
81
|
+
ctx.log(
|
|
82
|
+
`[Sample_PortfolioDailySummary_Grade1] analysed ${snapshotCount} snapshot(s) for ${ctx.date}`
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
// Use a domain-level outcome key; never use reserved keys like `status` or `error`.
|
|
87
|
+
outcome: snapshotCount === 0 ? 'NO_DATA' : 'OK',
|
|
88
|
+
asOfDate: ctx.date,
|
|
89
|
+
snapshotCount,
|
|
90
|
+
averagePortfolioValue
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade 2 sample computation — Per-entity risk change over a rolling window.
|
|
3
|
+
*
|
|
4
|
+
* This example builds on the Grade 1 sample by:
|
|
5
|
+
* - Operating in a clearly per-entity way (each entity is evaluated independently).
|
|
6
|
+
* - Working with two logical inputs (`risk_snapshots` and `portfolio_snapshots`).
|
|
7
|
+
* - Computing a small derived metric (risk delta) per entity.
|
|
8
|
+
* - Demonstrating safe handling of missing / partial data.
|
|
9
|
+
*
|
|
10
|
+
* It is still intentionally linear and readable; the comments explain each step so that
|
|
11
|
+
* new authors can copy this pattern when adding their own logic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration object for the computation.
|
|
16
|
+
*
|
|
17
|
+
* - `name` uniquely identifies this computation.
|
|
18
|
+
* - `type: 'per-entity'` tells the runtime to run once per entity (e.g. per portfolio).
|
|
19
|
+
* - `requires.risk_snapshots` and `requires.portfolio_snapshots` declare the inputs.
|
|
20
|
+
* - `storage.bigquery: true` persists the result into tenant-scoped BigQuery tables.
|
|
21
|
+
*/
|
|
22
|
+
exports.config = {
|
|
23
|
+
name: 'Sample_EntityRiskChange_Grade2',
|
|
24
|
+
type: 'per-entity',
|
|
25
|
+
skills: ['lib'],
|
|
26
|
+
lib: [],
|
|
27
|
+
requires: {
|
|
28
|
+
risk_snapshots: {
|
|
29
|
+
// 30-day lookback window of per-entity risk scores.
|
|
30
|
+
lookback: 30,
|
|
31
|
+
mandatory: true,
|
|
32
|
+
fields: ['date', 'entity_id', 'risk_score']
|
|
33
|
+
},
|
|
34
|
+
portfolio_snapshots: {
|
|
35
|
+
// Optional: we only need a name/label for display purposes.
|
|
36
|
+
lookback: 1,
|
|
37
|
+
mandatory: false,
|
|
38
|
+
fields: ['entity_id', 'display_name']
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
storage: {
|
|
42
|
+
bigquery: true
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Process function — computes the change in risk score for a single entity.
|
|
48
|
+
*
|
|
49
|
+
* For each entity on a given date:
|
|
50
|
+
* 1. Filter the `risk_snapshots` array down to the current entityId (if provided).
|
|
51
|
+
* 2. Sort snapshots by date to find the "previous" and "current" entries.
|
|
52
|
+
* 3. Compute a simple delta: current_risk - previous_risk.
|
|
53
|
+
* 4. Look up a human-readable portfolio name from `portfolio_snapshots` if present.
|
|
54
|
+
* 5. Return a single object that describes today's risk position for this entity.
|
|
55
|
+
*/
|
|
56
|
+
exports.process = function process(ctx) {
|
|
57
|
+
const entityId = ctx.entityId;
|
|
58
|
+
|
|
59
|
+
const allRiskSnapshots = Array.isArray(ctx.data && ctx.data.risk_snapshots)
|
|
60
|
+
? ctx.data.risk_snapshots
|
|
61
|
+
: [];
|
|
62
|
+
|
|
63
|
+
// Step 1: isolate this entity's rows (or all rows if entityId is not provided).
|
|
64
|
+
const entityRiskRows = allRiskSnapshots.filter((row) => {
|
|
65
|
+
if (!row) return false;
|
|
66
|
+
if (entityId == null) return true;
|
|
67
|
+
return String(row.entity_id) === String(entityId);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Step 2: sort by date ascending so the last element is the most recent snapshot.
|
|
71
|
+
entityRiskRows.sort((a, b) => {
|
|
72
|
+
const ad = a && a.date ? String(a.date) : '';
|
|
73
|
+
const bd = b && b.date ? String(b.date) : '';
|
|
74
|
+
return ad.localeCompare(bd);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const rowCount = entityRiskRows.length;
|
|
78
|
+
if (rowCount === 0) {
|
|
79
|
+
ctx.log(
|
|
80
|
+
`[Sample_EntityRiskChange_Grade2] no risk_snapshots found for entity ${String(
|
|
81
|
+
entityId || 'GLOBAL'
|
|
82
|
+
)} on ${ctx.date}`
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
outcome: 'NO_DATA',
|
|
87
|
+
asOfDate: ctx.date,
|
|
88
|
+
entityId: entityId || null,
|
|
89
|
+
currentRisk: null,
|
|
90
|
+
previousRisk: null,
|
|
91
|
+
riskDelta: null
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const latest = entityRiskRows[rowCount - 1];
|
|
96
|
+
const previous = rowCount > 1 ? entityRiskRows[rowCount - 2] : null;
|
|
97
|
+
|
|
98
|
+
const currentRisk =
|
|
99
|
+
latest && typeof latest.risk_score === 'number'
|
|
100
|
+
? latest.risk_score
|
|
101
|
+
: Number(latest && latest.risk_score);
|
|
102
|
+
const previousRisk =
|
|
103
|
+
previous && typeof previous.risk_score === 'number'
|
|
104
|
+
? previous.risk_score
|
|
105
|
+
: Number(previous && previous.risk_score);
|
|
106
|
+
|
|
107
|
+
const safeCurrent = Number.isFinite(currentRisk) ? currentRisk : null;
|
|
108
|
+
const safePrevious = Number.isFinite(previousRisk) ? previousRisk : null;
|
|
109
|
+
|
|
110
|
+
let riskDelta = null;
|
|
111
|
+
if (safeCurrent != null && safePrevious != null) {
|
|
112
|
+
riskDelta = safeCurrent - safePrevious;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Optional: look up a display name from portfolio_snapshots for nicer reporting.
|
|
116
|
+
let portfolioName = null;
|
|
117
|
+
const portfolioRows = Array.isArray(ctx.data && ctx.data.portfolio_snapshots)
|
|
118
|
+
? ctx.data.portfolio_snapshots
|
|
119
|
+
: [];
|
|
120
|
+
const matchingPortfolio = portfolioRows.find((row) => {
|
|
121
|
+
if (!row) return false;
|
|
122
|
+
if (entityId == null) return false;
|
|
123
|
+
return String(row.entity_id) === String(entityId);
|
|
124
|
+
});
|
|
125
|
+
if (matchingPortfolio && typeof matchingPortfolio.display_name === 'string') {
|
|
126
|
+
portfolioName = matchingPortfolio.display_name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
ctx.log(
|
|
130
|
+
`[Sample_EntityRiskChange_Grade2] entity ${String(
|
|
131
|
+
entityId || 'GLOBAL'
|
|
132
|
+
)} risk=${safeCurrent} (delta=${riskDelta}) on ${ctx.date}`
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
outcome: 'OK',
|
|
137
|
+
asOfDate: ctx.date,
|
|
138
|
+
entityId: entityId || null,
|
|
139
|
+
portfolioName,
|
|
140
|
+
currentRisk: safeCurrent,
|
|
141
|
+
previousRisk: safePrevious,
|
|
142
|
+
riskDelta
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade 3 sample computation — Simple alert surface for risk spikes.
|
|
3
|
+
*
|
|
4
|
+
* This example is intentionally still single-file and easy to read, but it
|
|
5
|
+
* shows a slightly more "production-like" pattern:
|
|
6
|
+
* - It depends on the Grade 2 computation's result by name via `dependencies`.
|
|
7
|
+
* - It treats the upstream result as an input table in `requires`.
|
|
8
|
+
* - It emits a compact list of "alerts" that could be consumed by downstream
|
|
9
|
+
* dashboards or notification systems.
|
|
10
|
+
*
|
|
11
|
+
* This is not wired into any real alerting system; it is a didactic example
|
|
12
|
+
* that demonstrates how to compose computations safely.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration object for the computation.
|
|
17
|
+
*
|
|
18
|
+
* - `dependencies` lists another computation by name. The planner ensures that
|
|
19
|
+
* the dependency runs first for the target date / entity set before this one.
|
|
20
|
+
* - `requires.entity_risk_change` models the dependency's output as a table-like
|
|
21
|
+
* input; in a real deployment this would be backed by storage (e.g. BigQuery).
|
|
22
|
+
*/
|
|
23
|
+
exports.config = {
|
|
24
|
+
name: 'Sample_RiskSpikeAlerts_Grade3',
|
|
25
|
+
skills: ['lib'],
|
|
26
|
+
lib: [],
|
|
27
|
+
dependencies: ['Sample_EntityRiskChange_Grade2'],
|
|
28
|
+
requires: {
|
|
29
|
+
entity_risk_change: {
|
|
30
|
+
// The runtime will typically resolve this from the dependency's output.
|
|
31
|
+
lookback: 1,
|
|
32
|
+
mandatory: true,
|
|
33
|
+
fields: [
|
|
34
|
+
'asOfDate',
|
|
35
|
+
'entityId',
|
|
36
|
+
'portfolioName',
|
|
37
|
+
'currentRisk',
|
|
38
|
+
'previousRisk',
|
|
39
|
+
'riskDelta'
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
storage: {
|
|
44
|
+
bigquery: true
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Process function — builds a list of simple alert objects when risk jumps.
|
|
50
|
+
*
|
|
51
|
+
* Behaviour:
|
|
52
|
+
* 1. Read the `entity_risk_change` rows for the current date.
|
|
53
|
+
* 2. Filter to entities whose absolute riskDelta is above a fixed threshold.
|
|
54
|
+
* 3. Map each row to a small, explicit alert object.
|
|
55
|
+
* 4. Return a payload containing:
|
|
56
|
+
* - outcome: "OK" or "NO_ALERTS".
|
|
57
|
+
* - asOfDate: the date we evaluated.
|
|
58
|
+
* - alerts: array of alert objects (possibly empty).
|
|
59
|
+
*
|
|
60
|
+
* NOTE: This computation does not send notifications itself; it produces clean,
|
|
61
|
+
* queryable data that other systems can use as a source of truth.
|
|
62
|
+
*/
|
|
63
|
+
exports.process = function process(ctx) {
|
|
64
|
+
const rows = Array.isArray(ctx.data && ctx.data.entity_risk_change)
|
|
65
|
+
? ctx.data.entity_risk_change
|
|
66
|
+
: [];
|
|
67
|
+
|
|
68
|
+
const THRESHOLD = 5; // Example threshold for a "meaningful" risk move.
|
|
69
|
+
|
|
70
|
+
const alerts = [];
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
if (!row) continue;
|
|
73
|
+
|
|
74
|
+
const delta = row.riskDelta;
|
|
75
|
+
if (!Number.isFinite(delta)) continue;
|
|
76
|
+
|
|
77
|
+
if (Math.abs(delta) < THRESHOLD) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
alerts.push({
|
|
82
|
+
entityId: row.entityId || null,
|
|
83
|
+
portfolioName: typeof row.portfolioName === 'string' ? row.portfolioName : null,
|
|
84
|
+
asOfDate: row.asOfDate || ctx.date,
|
|
85
|
+
currentRisk: Number.isFinite(row.currentRisk) ? row.currentRisk : null,
|
|
86
|
+
previousRisk: Number.isFinite(row.previousRisk) ? row.previousRisk : null,
|
|
87
|
+
riskDelta: delta,
|
|
88
|
+
direction: delta > 0 ? 'INCREASE' : 'DECREASE'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ctx.log(
|
|
93
|
+
`[Sample_RiskSpikeAlerts_Grade3] generated ${alerts.length} alert(s) for ${ctx.date} with threshold ${THRESHOLD}`
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
outcome: alerts.length === 0 ? 'NO_ALERTS' : 'OK',
|
|
98
|
+
asOfDate: ctx.date,
|
|
99
|
+
alerts
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Analytics load registry: time-bucketed Firestore docs for "what to sync".
|
|
3
|
+
* API calls recordAnalyticsWrite(db, sourceKey) after writing analytics data; the loader
|
|
4
|
+
* reads period docs to discover which sources had writes in the run window.
|
|
5
|
+
* Documents are named by time period (e.g. YYYY-MM-DD-HH for 2h buckets) to avoid 1MB limit;
|
|
6
|
+
* each doc has sources: { [sourceKey]: lastWrittenAt } and expire_at for TTL (30 days).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BUCKET_HOURS = parseInt(process.env.REGISTRY_BUCKET_HOURS || '2', 10) || 2;
|
|
10
|
+
const TTL_DAYS = parseInt(process.env.REGISTRY_TTL_DAYS || '30', 10) || 30;
|
|
11
|
+
const REGISTRY_COLLECTION = process.env.FIRESTORE_ANALYTICS_REGISTRY_COLLECTION || 'analytics_load_registry';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the period document ID for a given date (e.g. "2025-03-13-00" for 2h buckets).
|
|
15
|
+
* @param {Date} date - Reference time
|
|
16
|
+
* @returns {string} Period doc ID (YYYY-MM-DD-HH)
|
|
17
|
+
*/
|
|
18
|
+
function getPeriodDocId(date = new Date()) {
|
|
19
|
+
const y = date.getUTCFullYear();
|
|
20
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
21
|
+
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
22
|
+
const hour = date.getUTCHours();
|
|
23
|
+
const bucketHour = Math.floor(hour / BUCKET_HOURS) * BUCKET_HOURS;
|
|
24
|
+
const h = String(bucketHour).padStart(2, '0');
|
|
25
|
+
return `${y}-${m}-${d}-${h}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get period start date (UTC) for expire_at calculation.
|
|
30
|
+
* @param {string} periodDocId - e.g. "2025-03-13-00"
|
|
31
|
+
* @returns {Date} Start of that period (UTC)
|
|
32
|
+
*/
|
|
33
|
+
function getPeriodStartFromDocId(periodDocId) {
|
|
34
|
+
const [y, m, d, h] = periodDocId.split('-').map(Number);
|
|
35
|
+
return new Date(Date.UTC(y, m - 1, d, h, 0, 0, 0));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Record that a source was written to (for the analytics loader). Fire-and-forget; logs errors only.
|
|
40
|
+
* Updates the current period's registry doc: merge source key + writtenAt into sources, set expire_at.
|
|
41
|
+
* @param {object} db - Firestore instance
|
|
42
|
+
* @param {string} sourceKey - e.g. 'watchlists', 'analytics_events_pi_page_view'
|
|
43
|
+
*/
|
|
44
|
+
async function recordAnalyticsWrite(db, sourceKey) {
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const periodDocId = getPeriodDocId(now);
|
|
47
|
+
const periodStart = getPeriodStartFromDocId(periodDocId);
|
|
48
|
+
const expireAt = new Date(periodStart);
|
|
49
|
+
expireAt.setUTCDate(expireAt.getUTCDate() + TTL_DAYS);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const col = db.collection(REGISTRY_COLLECTION);
|
|
53
|
+
const ref = col.doc(periodDocId);
|
|
54
|
+
await ref.set(
|
|
55
|
+
{
|
|
56
|
+
[`sources.${sourceKey}`]: now,
|
|
57
|
+
expire_at: expireAt
|
|
58
|
+
},
|
|
59
|
+
{ merge: true }
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('[analytics_registry] recordAnalyticsWrite failed:', error.message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* List period doc IDs that overlap the given time window [since, now].
|
|
68
|
+
* @param {Date} since - Start of window
|
|
69
|
+
* @param {Date} end - End of window (default now)
|
|
70
|
+
* @returns {string[]} Period doc IDs in range
|
|
71
|
+
*/
|
|
72
|
+
function getPeriodDocIdsInWindow(since, end = new Date()) {
|
|
73
|
+
const ids = [];
|
|
74
|
+
const cursor = new Date(since);
|
|
75
|
+
cursor.setUTCMinutes(0, 0, 0);
|
|
76
|
+
const hour = cursor.getUTCHours();
|
|
77
|
+
const bucketHour = Math.floor(hour / BUCKET_HOURS) * BUCKET_HOURS;
|
|
78
|
+
cursor.setUTCHours(bucketHour, 0, 0, 0);
|
|
79
|
+
|
|
80
|
+
while (cursor <= end) {
|
|
81
|
+
ids.push(getPeriodDocId(cursor));
|
|
82
|
+
cursor.setUTCHours(cursor.getUTCHours() + BUCKET_HOURS);
|
|
83
|
+
}
|
|
84
|
+
return [...new Set(ids)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read registry docs for the given period IDs and return the union of source keys that had writes.
|
|
89
|
+
* @param {object} db - Firestore instance
|
|
90
|
+
* @param {string[]} periodDocIds - Period document IDs to read
|
|
91
|
+
* @returns {Promise<Set<string>>} Set of source keys
|
|
92
|
+
*/
|
|
93
|
+
async function getSourceKeysFromRegistry(db, periodDocIds) {
|
|
94
|
+
const keys = new Set();
|
|
95
|
+
const col = db.collection(REGISTRY_COLLECTION);
|
|
96
|
+
await Promise.all(
|
|
97
|
+
periodDocIds.map(async (id) => {
|
|
98
|
+
const snap = await col.doc(id).get();
|
|
99
|
+
if (!snap.exists) return;
|
|
100
|
+
const data = snap.data();
|
|
101
|
+
const sources = data && data.sources && typeof data.sources === 'object' ? data.sources : {};
|
|
102
|
+
Object.keys(sources).forEach((k) => keys.add(k));
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
return keys;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
recordAnalyticsWrite,
|
|
110
|
+
getPeriodDocId,
|
|
111
|
+
getPeriodDocIdsInWindow,
|
|
112
|
+
getSourceKeysFromRegistry,
|
|
113
|
+
REGISTRY_COLLECTION,
|
|
114
|
+
BUCKET_HOURS,
|
|
115
|
+
TTL_DAYS
|
|
116
|
+
};
|