bulltrackers-module 1.0.995 → 1.0.997
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/workspace.js +54 -6
- 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 +108 -0
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade2-entity-risk-change.js +116 -0
- package/functions/computation-system-v3/framework/docs/authoring/sample-computations/grade3-alert-on-risk-spike.js +162 -0
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade 1 sample computation — basic per‑user portfolio snapshot using SDK helpers.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally the smallest realistic example of a V3 computation:
|
|
5
|
+
* - Minimal `config`: only `name`, `type`, and `storage`.
|
|
6
|
+
* - No manual `config.requires`: the engine infers it from the code using
|
|
7
|
+
* DataContracts (`__contracts`) attached to `lib.*` helpers.
|
|
8
|
+
* - Straight‑line `process(ctx)` that reads a single table and returns a
|
|
9
|
+
* small, envelope‑compatible result object.
|
|
10
|
+
*
|
|
11
|
+
* It is designed to be copied, modified, and extended by users.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration object for the computation.
|
|
16
|
+
*
|
|
17
|
+
* - `name` is unique per tenant and is used for scheduling and storage paths.
|
|
18
|
+
* - `type: 'per-entity'` means the computation runs once per entity ID
|
|
19
|
+
* (e.g. once per signed‑in user or PI, depending on the calling DAG).
|
|
20
|
+
* - `storage.bigquery: true` persists the result into the standard
|
|
21
|
+
* `computation_results_v3` dataset for this tenant.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: We deliberately do NOT define `config.requires` here. The engine
|
|
24
|
+
* analyses the code in `process(ctx)` and infers a safe `requires` block
|
|
25
|
+
* from the `ctx.lib.portfolio.*` calls and `ctx.data.portfolio_snapshots`
|
|
26
|
+
* access (see `docs/authoring/config-resolution.md`).
|
|
27
|
+
*/
|
|
28
|
+
exports.config = {
|
|
29
|
+
name: 'Sample_PortfolioSnapshot_Grade1',
|
|
30
|
+
type: 'per-entity',
|
|
31
|
+
category: 'examples',
|
|
32
|
+
storage: {
|
|
33
|
+
bigquery: true
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Process function — summarises the latest portfolio snapshot for one entity.
|
|
39
|
+
*
|
|
40
|
+
* For the current `entityId` and `date`, this function:
|
|
41
|
+
* 1. Uses `lib.dataHelpers.getEntityRows` to get rows from
|
|
42
|
+
* `ctx.data.portfolio_snapshots` that belong to the current entity.
|
|
43
|
+
* 2. Sorts those rows by date ascending using `lib.dataHelpers.sortByDateAsc`.
|
|
44
|
+
* 3. Extracts the latest `portfolio_data` payload via `lib.portfolio.extractPortfolioData`.
|
|
45
|
+
* 4. Derives simple metrics using `lib.portfolio` helpers:
|
|
46
|
+
* - `calculateTotalInvested`
|
|
47
|
+
* - `calculateTotalValue`
|
|
48
|
+
* - `calculateWinRatio`
|
|
49
|
+
* 5. Returns a compact result object with no reserved keys (`status`, `error`).
|
|
50
|
+
*
|
|
51
|
+
* This pattern matches how production computations in `../computations` use the SDK.
|
|
52
|
+
*
|
|
53
|
+
* @param {import('../core/ContextBuilder').ComputationContext} ctx
|
|
54
|
+
*/
|
|
55
|
+
exports.process = function process(ctx) {
|
|
56
|
+
const { data, entityId, date, lib, log } = ctx;
|
|
57
|
+
|
|
58
|
+
const rows = lib.dataHelpers.getEntityRows(data['portfolio_snapshots'], entityId);
|
|
59
|
+
const sorted = lib.dataHelpers.sortByDateAsc(rows);
|
|
60
|
+
const latest = sorted.length > 0 ? sorted[sorted.length - 1] : null;
|
|
61
|
+
|
|
62
|
+
if (!latest) {
|
|
63
|
+
if (log) {
|
|
64
|
+
log(
|
|
65
|
+
`[Sample_PortfolioSnapshot_Grade1] No portfolio_snapshots rows for entity ${String(
|
|
66
|
+
entityId
|
|
67
|
+
)} on ${date}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
outcome: 'NO_DATA',
|
|
73
|
+
asOfDate: date,
|
|
74
|
+
entityId: entityId ?? null,
|
|
75
|
+
totalInvested: 0,
|
|
76
|
+
totalValue: 0,
|
|
77
|
+
winRatioPercent: 0
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const portfolioData = lib.portfolio.extractPortfolioData(latest);
|
|
82
|
+
const positions = lib.portfolio.extractPositions(portfolioData);
|
|
83
|
+
|
|
84
|
+
const totalInvested = lib.portfolio.calculateTotalInvested(positions);
|
|
85
|
+
const totalValue = lib.portfolio.calculateTotalValue(positions);
|
|
86
|
+
const winRatioPercent = lib.portfolio.calculateWinRatio(positions);
|
|
87
|
+
|
|
88
|
+
if (log) {
|
|
89
|
+
log(
|
|
90
|
+
`[Sample_PortfolioSnapshot_Grade1] entity=${String(
|
|
91
|
+
entityId
|
|
92
|
+
)} invested=${totalInvested.toFixed(2)} value=${totalValue.toFixed(
|
|
93
|
+
2
|
|
94
|
+
)} winRatio=${winRatioPercent.toFixed(2)}%`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
// Domain‑level outcome; do not use reserved keys like `status` or `error`.
|
|
100
|
+
outcome: 'OK',
|
|
101
|
+
asOfDate: date,
|
|
102
|
+
entityId: entityId ?? null,
|
|
103
|
+
totalInvested: Number(totalInvested.toFixed(2)),
|
|
104
|
+
totalValue: Number(totalValue.toFixed(2)),
|
|
105
|
+
winRatioPercent: Number(winRatioPercent.toFixed(2))
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade 2 sample computation — PI risk and AUM summary over a short window.
|
|
3
|
+
*
|
|
4
|
+
* This example builds directly on the real `pi_rankings` table and uses the
|
|
5
|
+
* `lib.rankings` and `lib.dataHelpers` modules exactly as production recipes do.
|
|
6
|
+
*
|
|
7
|
+
* It demonstrates:
|
|
8
|
+
* - Per‑entity operation (`type: 'per-entity'` for PI entities).
|
|
9
|
+
* - A small but explicit `config.requires` override to control lookback,
|
|
10
|
+
* fields, and mandatory flags.
|
|
11
|
+
* - Safe handling of missing data across days.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration object for the computation.
|
|
16
|
+
*
|
|
17
|
+
* - `name` and `type` follow the same pattern as `RiskScoreIncrease`.
|
|
18
|
+
* - `requires.pi_rankings` is kept intentionally small but explicit so the
|
|
19
|
+
* author can see and tune lookback/fields in one place.
|
|
20
|
+
* - `storage.bigquery: true` persists results for later dashboard use.
|
|
21
|
+
*/
|
|
22
|
+
exports.config = {
|
|
23
|
+
name: 'Sample_PIRiskAndAum_Grade2',
|
|
24
|
+
type: 'per-entity',
|
|
25
|
+
category: 'examples',
|
|
26
|
+
|
|
27
|
+
requires: {
|
|
28
|
+
pi_rankings: {
|
|
29
|
+
// 7‑day window of rankings for this PI, similar to DashboardPage.
|
|
30
|
+
lookback: 7,
|
|
31
|
+
mandatory: false,
|
|
32
|
+
fields: ['pi_id', 'rankings_data', 'username', 'date']
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
storage: {
|
|
37
|
+
bigquery: true
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Process function — summarises basic risk and AUM information for a single PI.
|
|
43
|
+
*
|
|
44
|
+
* For each PI (`entityId`) this function:
|
|
45
|
+
* 1. Pulls that PI's `pi_rankings` rows via `lib.dataHelpers.getEntityRows`.
|
|
46
|
+
* 2. Sorts the rows by date, then picks the latest one in the lookback window.
|
|
47
|
+
* 3. Uses `lib.rankings` helpers to extract:
|
|
48
|
+
* - current risk score and risk tier,
|
|
49
|
+
* - total gain,
|
|
50
|
+
* - AUM value and tier label.
|
|
51
|
+
* 4. Returns a compact result object, suitable for direct consumption by
|
|
52
|
+
* dashboards or downstream computations.
|
|
53
|
+
*
|
|
54
|
+
* @param {import('../core/ContextBuilder').ComputationContext} ctx
|
|
55
|
+
*/
|
|
56
|
+
exports.process = function process(ctx) {
|
|
57
|
+
const { data, entityId, date, lib, log } = ctx;
|
|
58
|
+
|
|
59
|
+
const rows = lib.dataHelpers.getEntityRows(data['pi_rankings'], entityId);
|
|
60
|
+
const sorted = lib.dataHelpers.sortByDateAsc(rows);
|
|
61
|
+
const latest = sorted.length > 0 ? sorted[sorted.length - 1] : null;
|
|
62
|
+
|
|
63
|
+
if (!latest) {
|
|
64
|
+
if (log) {
|
|
65
|
+
log(
|
|
66
|
+
`[Sample_PIRiskAndAum_Grade2] No pi_rankings rows for entity ${String(
|
|
67
|
+
entityId
|
|
68
|
+
)} in window ending ${date}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
outcome: 'NO_DATA',
|
|
74
|
+
asOfDate: date,
|
|
75
|
+
entityId: entityId ?? null,
|
|
76
|
+
username: null,
|
|
77
|
+
riskScore: null,
|
|
78
|
+
riskTier: null,
|
|
79
|
+
totalGain: null,
|
|
80
|
+
aumValue: null,
|
|
81
|
+
aumTierLabel: null
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rankingsData = lib.rankings.extractRankingsData(latest);
|
|
86
|
+
const username = lib.rankings.getUsername(latest);
|
|
87
|
+
|
|
88
|
+
const riskScore = lib.rankings.getRiskScore(rankingsData);
|
|
89
|
+
const riskTier = lib.rankings.getRiskTier(riskScore);
|
|
90
|
+
|
|
91
|
+
const totalGain = lib.rankings.getTotalGain(rankingsData);
|
|
92
|
+
const aumValue = lib.rankings.getAUM(rankingsData);
|
|
93
|
+
const aumTierId = lib.rankings.getAUMTier(rankingsData);
|
|
94
|
+
const aumTierLabel = lib.rankings.getAUMTierLabel(aumTierId);
|
|
95
|
+
|
|
96
|
+
if (log) {
|
|
97
|
+
log(
|
|
98
|
+
`[Sample_PIRiskAndAum_Grade2] entity=${String(
|
|
99
|
+
entityId
|
|
100
|
+
)} risk=${riskScore} (${riskTier}), AUM=${aumValue}, gain=${totalGain}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
outcome: 'OK',
|
|
106
|
+
asOfDate: date,
|
|
107
|
+
entityId: entityId ?? null,
|
|
108
|
+
username: username || null,
|
|
109
|
+
riskScore,
|
|
110
|
+
riskTier,
|
|
111
|
+
totalGain,
|
|
112
|
+
aumValue,
|
|
113
|
+
aumTierLabel
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade 3 sample computation — asset + PI risk alerts (composed from real tables).
|
|
3
|
+
*
|
|
4
|
+
* This example shows an end‑to‑end, production‑style pattern:
|
|
5
|
+
* - Depends on the existing `GlobalAumPerAsset30D` computation so it runs first.
|
|
6
|
+
* - Reads its materialised results from the `results` table via data helpers.
|
|
7
|
+
* - Combines that with live `pi_rankings` data to build two alert feeds:
|
|
8
|
+
* • high‑AUM assets
|
|
9
|
+
* • high‑risk, high‑AUM Popular Investors
|
|
10
|
+
*
|
|
11
|
+
* The intent is to be a “perfect role model”: everything is grounded in the
|
|
12
|
+
* current framework, no invented tables, and every step is documented.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for the computation.
|
|
17
|
+
*
|
|
18
|
+
* Key points:
|
|
19
|
+
* - `type: 'global'` because we aggregate across all entities.
|
|
20
|
+
* - `dependencies` ensures `GlobalAumPerAsset30D` has written its output to
|
|
21
|
+
* the `results` table before this computation runs.
|
|
22
|
+
* - `requires.results` explicitly filters that table to only the parent
|
|
23
|
+
* computation’s rows, mirroring `GlobalAumPerAsset30D`.
|
|
24
|
+
* - `requires.pi_rankings` gives us current PI risk/AUM data for scoring.
|
|
25
|
+
*/
|
|
26
|
+
exports.config = {
|
|
27
|
+
name: 'Sample_AssetAndRiskAlerts_Grade3',
|
|
28
|
+
type: 'global',
|
|
29
|
+
category: 'examples',
|
|
30
|
+
|
|
31
|
+
// Ensure the upstream aggregation has run before this computation executes.
|
|
32
|
+
dependencies: ['globalaumperasset30d'],
|
|
33
|
+
|
|
34
|
+
requires: {
|
|
35
|
+
// Parent computation results, constrained to GlobalAumPerAsset30D.
|
|
36
|
+
results: {
|
|
37
|
+
lookback: 0,
|
|
38
|
+
mandatory: true,
|
|
39
|
+
fields: ['result_data', 'computation_name', 'date', 'entity_id'],
|
|
40
|
+
filter: { computation_name: 'globalaumperasset30d' }
|
|
41
|
+
},
|
|
42
|
+
// Current PI rankings window; used to identify high‑risk, high‑AUM PIs.
|
|
43
|
+
pi_rankings: {
|
|
44
|
+
lookback: 7,
|
|
45
|
+
mandatory: false,
|
|
46
|
+
fields: ['pi_id', 'rankings_data', 'username', 'date']
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
storage: {
|
|
51
|
+
bigquery: true
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Process function — produces alert lists for assets and PIs.
|
|
57
|
+
*
|
|
58
|
+
* High‑level behaviour:
|
|
59
|
+
*
|
|
60
|
+
* 1. Reads `results` for `GlobalAumPerAsset30D` using:
|
|
61
|
+
* - `lib.dataHelpers.normaliseParentResults` to flatten rows
|
|
62
|
+
* - `lib.dataHelpers.parseResultData` to parse `result_data`
|
|
63
|
+
* and emits **asset AUM alerts** where total AUM exceeds a fixed threshold.
|
|
64
|
+
*
|
|
65
|
+
* 2. Reads `pi_rankings` via `lib.dataHelpers.toArray` and `lib.rankings`
|
|
66
|
+
* helpers to construct **PI risk alerts** for Popular Investors that are
|
|
67
|
+
* both high‑risk and above a minimum AUM tier.
|
|
68
|
+
*
|
|
69
|
+
* 3. Returns a single, envelope‑compatible result object:
|
|
70
|
+
* - `outcome`: `"OK"` or `"NO_ALERTS"`
|
|
71
|
+
* - `asOfDate`: target date
|
|
72
|
+
* - `assetAlerts`: array of asset‑level alerts
|
|
73
|
+
* - `piRiskAlerts`: array of PI‑level alerts
|
|
74
|
+
*
|
|
75
|
+
* @param {import('../core/ContextBuilder').ComputationContext} ctx
|
|
76
|
+
*/
|
|
77
|
+
exports.process = function process(ctx) {
|
|
78
|
+
const { data, date, lib, log } = ctx;
|
|
79
|
+
|
|
80
|
+
const { normaliseParentResults, parseResultData, toArray } = lib.dataHelpers;
|
|
81
|
+
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// 1. Asset‑level alerts from GlobalAumPerAsset30D parent results
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
const parentRows = normaliseParentResults(data['results']);
|
|
86
|
+
const assetAlerts = [];
|
|
87
|
+
|
|
88
|
+
// Example threshold: only alert on assets with at least 50k AUM.
|
|
89
|
+
const ASSET_AUM_THRESHOLD = 50000;
|
|
90
|
+
|
|
91
|
+
for (const row of parentRows) {
|
|
92
|
+
const parsed = parseResultData(row) || row;
|
|
93
|
+
|
|
94
|
+
// GlobalAumPerAsset30D returns an array of { symbol, amount } entries
|
|
95
|
+
// as its top‑level `result`. We tolerate both raw arrays and envelopes.
|
|
96
|
+
const items = Array.isArray(parsed)
|
|
97
|
+
? parsed
|
|
98
|
+
: (Array.isArray(parsed.result) ? parsed.result : []);
|
|
99
|
+
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
if (!item || item.symbol == null || item.amount == null) continue;
|
|
102
|
+
const amount = Number(item.amount);
|
|
103
|
+
if (!Number.isFinite(amount) || amount < ASSET_AUM_THRESHOLD) continue;
|
|
104
|
+
|
|
105
|
+
assetAlerts.push({
|
|
106
|
+
type: 'ASSET_AUM',
|
|
107
|
+
symbol: item.symbol,
|
|
108
|
+
amount: Number(amount.toFixed(2)),
|
|
109
|
+
asOfDate: date
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
// 2. PI‑level risk alerts from pi_rankings
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
const rankings = toArray(data['pi_rankings'] || []);
|
|
118
|
+
const piRiskAlerts = [];
|
|
119
|
+
|
|
120
|
+
// Example policy: alert on "high" or above risk and mid‑tier AUM or higher.
|
|
121
|
+
const MIN_RISK_SCORE = 6;
|
|
122
|
+
const MIN_AUM_TIER = 3;
|
|
123
|
+
|
|
124
|
+
rankings.forEach((row) => {
|
|
125
|
+
const rData = lib.rankings.extractRankingsData(row);
|
|
126
|
+
if (!rData) return;
|
|
127
|
+
|
|
128
|
+
const riskScore = lib.rankings.getRiskScore(rData);
|
|
129
|
+
const riskTier = lib.rankings.getRiskTier(riskScore);
|
|
130
|
+
const aumValue = lib.rankings.getAUM(rData);
|
|
131
|
+
const aumTierId = lib.rankings.getAUMTier(rData);
|
|
132
|
+
|
|
133
|
+
if (riskScore < MIN_RISK_SCORE || aumTierId < MIN_AUM_TIER) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
piRiskAlerts.push({
|
|
138
|
+
type: 'PI_RISK',
|
|
139
|
+
piId: lib.rankings.getPiId(row),
|
|
140
|
+
username: lib.rankings.getUsername(row),
|
|
141
|
+
riskScore,
|
|
142
|
+
riskTier,
|
|
143
|
+
aumValue,
|
|
144
|
+
aumTierLabel: lib.rankings.getAUMTierLabel(aumTierId)
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (log) {
|
|
149
|
+
log(
|
|
150
|
+
`[Sample_AssetAndRiskAlerts_Grade3] generated ${assetAlerts.length} asset alert(s) and ${piRiskAlerts.length} PI risk alert(s) for ${date}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
// Use a domain‑level outcome; never return reserved keys `status` or `error`.
|
|
156
|
+
outcome: assetAlerts.length === 0 && piRiskAlerts.length === 0 ? 'NO_ALERTS' : 'OK',
|
|
157
|
+
asOfDate: date,
|
|
158
|
+
assetAlerts,
|
|
159
|
+
piRiskAlerts
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|