bulltrackers-module 1.0.995 → 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/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 +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/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,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
|
+
|