bulltrackers-module 1.0.996 → 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.
@@ -1,93 +1,108 @@
1
1
  /**
2
- * Grade 1 sample computation — Daily portfolio value summary.
2
+ * Grade 1 sample computation — basic per‑user portfolio snapshot using SDK helpers.
3
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).
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.
9
10
  *
10
- * The goal is to be a "perfect role model" for new authors, not to be clever.
11
+ * It is designed to be copied, modified, and extended by users.
11
12
  */
12
13
 
13
14
  /**
14
15
  * Configuration object for the computation.
15
16
  *
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.
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`).
23
27
  */
24
28
  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
- },
29
+ name: 'Sample_PortfolioSnapshot_Grade1',
30
+ type: 'per-entity',
31
+ category: 'examples',
36
32
  storage: {
37
33
  bigquery: true
38
34
  }
39
35
  };
40
36
 
41
37
  /**
42
- * Process function — called by the runtime for a single execution target.
38
+ * Process function — summarises the latest portfolio snapshot for one entity.
43
39
  *
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.
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`).
49
50
  *
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.
51
+ * This pattern matches how production computations in `../computations` use the SDK.
52
+ *
53
+ * @param {import('../core/ContextBuilder').ComputationContext} ctx
60
54
  */
61
55
  exports.process = function process(ctx) {
62
- const snapshots = Array.isArray(ctx.data && ctx.data.portfolio_snapshots)
63
- ? ctx.data.portfolio_snapshots
64
- : [];
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;
65
61
 
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);
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
+ );
72
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
+ };
73
79
  }
74
80
 
75
- const snapshotCount = numericValues.length;
76
- const averagePortfolioValue =
77
- snapshotCount === 0
78
- ? null
79
- : numericValues.reduce((sum, value) => sum + value, 0) / snapshotCount;
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);
80
87
 
81
- ctx.log(
82
- `[Sample_PortfolioDailySummary_Grade1] analysed ${snapshotCount} snapshot(s) for ${ctx.date}`
83
- );
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
+ }
84
97
 
85
98
  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
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))
91
106
  };
92
107
  };
93
108
 
@@ -1,145 +1,116 @@
1
1
  /**
2
- * Grade 2 sample computation — Per-entity risk change over a rolling window.
2
+ * Grade 2 sample computation — PI risk and AUM summary over a short window.
3
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.
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.
9
6
  *
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.
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
12
  */
13
13
 
14
14
  /**
15
15
  * Configuration object for the computation.
16
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.
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
21
  */
22
22
  exports.config = {
23
- name: 'Sample_EntityRiskChange_Grade2',
23
+ name: 'Sample_PIRiskAndAum_Grade2',
24
24
  type: 'per-entity',
25
- skills: ['lib'],
26
- lib: [],
25
+ category: 'examples',
26
+
27
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,
28
+ pi_rankings: {
29
+ // 7‑day window of rankings for this PI, similar to DashboardPage.
30
+ lookback: 7,
37
31
  mandatory: false,
38
- fields: ['entity_id', 'display_name']
32
+ fields: ['pi_id', 'rankings_data', 'username', 'date']
39
33
  }
40
34
  },
35
+
41
36
  storage: {
42
37
  bigquery: true
43
38
  }
44
39
  };
45
40
 
46
41
  /**
47
- * Process function — computes the change in risk score for a single entity.
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.
48
53
  *
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.
54
+ * @param {import('../core/ContextBuilder').ComputationContext} ctx
55
55
  */
56
56
  exports.process = function process(ctx) {
57
- const entityId = ctx.entityId;
57
+ const { data, entityId, date, lib, log } = ctx;
58
58
 
59
- const allRiskSnapshots = Array.isArray(ctx.data && ctx.data.risk_snapshots)
60
- ? ctx.data.risk_snapshots
61
- : [];
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
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
- );
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
+ }
84
71
 
85
72
  return {
86
73
  outcome: 'NO_DATA',
87
- asOfDate: ctx.date,
88
- entityId: entityId || null,
89
- currentRisk: null,
90
- previousRisk: null,
91
- riskDelta: null
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
92
82
  };
93
83
  }
94
84
 
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);
85
+ const rankingsData = lib.rankings.extractRankingsData(latest);
86
+ const username = lib.rankings.getUsername(latest);
106
87
 
107
- const safeCurrent = Number.isFinite(currentRisk) ? currentRisk : null;
108
- const safePrevious = Number.isFinite(previousRisk) ? previousRisk : null;
88
+ const riskScore = lib.rankings.getRiskScore(rankingsData);
89
+ const riskTier = lib.rankings.getRiskTier(riskScore);
109
90
 
110
- let riskDelta = null;
111
- if (safeCurrent != null && safePrevious != null) {
112
- riskDelta = safeCurrent - safePrevious;
113
- }
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);
114
95
 
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;
96
+ if (log) {
97
+ log(
98
+ `[Sample_PIRiskAndAum_Grade2] entity=${String(
99
+ entityId
100
+ )} risk=${riskScore} (${riskTier}), AUM=${aumValue}, gain=${totalGain}`
101
+ );
127
102
  }
128
103
 
129
- ctx.log(
130
- `[Sample_EntityRiskChange_Grade2] entity ${String(
131
- entityId || 'GLOBAL'
132
- )} risk=${safeCurrent} (delta=${riskDelta}) on ${ctx.date}`
133
- );
134
-
135
104
  return {
136
105
  outcome: 'OK',
137
- asOfDate: ctx.date,
138
- entityId: entityId || null,
139
- portfolioName,
140
- currentRisk: safeCurrent,
141
- previousRisk: safePrevious,
142
- riskDelta
106
+ asOfDate: date,
107
+ entityId: entityId ?? null,
108
+ username: username || null,
109
+ riskScore,
110
+ riskTier,
111
+ totalGain,
112
+ aumValue,
113
+ aumTierLabel
143
114
  };
144
115
  };
145
116
 
@@ -1,102 +1,162 @@
1
1
  /**
2
- * Grade 3 sample computation — Simple alert surface for risk spikes.
2
+ * Grade 3 sample computation — asset + PI risk alerts (composed from real tables).
3
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.
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
10
  *
11
- * This is not wired into any real alerting system; it is a didactic example
12
- * that demonstrates how to compose computations safely.
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
13
  */
14
14
 
15
15
  /**
16
- * Configuration object for the computation.
16
+ * Configuration for the computation.
17
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).
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.
22
25
  */
23
26
  exports.config = {
24
- name: 'Sample_RiskSpikeAlerts_Grade3',
25
- skills: ['lib'],
26
- lib: [],
27
- dependencies: ['Sample_EntityRiskChange_Grade2'],
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
+
28
34
  requires: {
29
- entity_risk_change: {
30
- // The runtime will typically resolve this from the dependency's output.
31
- lookback: 1,
35
+ // Parent computation results, constrained to GlobalAumPerAsset30D.
36
+ results: {
37
+ lookback: 0,
32
38
  mandatory: true,
33
- fields: [
34
- 'asOfDate',
35
- 'entityId',
36
- 'portfolioName',
37
- 'currentRisk',
38
- 'previousRisk',
39
- 'riskDelta'
40
- ]
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']
41
47
  }
42
48
  },
49
+
43
50
  storage: {
44
51
  bigquery: true
45
52
  }
46
53
  };
47
54
 
48
55
  /**
49
- * Process function — builds a list of simple alert objects when risk jumps.
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.
50
64
  *
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).
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.
59
68
  *
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.
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
62
76
  */
63
77
  exports.process = function process(ctx) {
64
- const rows = Array.isArray(ctx.data && ctx.data.entity_risk_change)
65
- ? ctx.data.entity_risk_change
66
- : [];
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;
67
90
 
68
- const THRESHOLD = 5; // Example threshold for a "meaningful" risk move.
91
+ for (const row of parentRows) {
92
+ const parsed = parseResultData(row) || row;
69
93
 
70
- const alerts = [];
71
- for (const row of rows) {
72
- if (!row) continue;
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 : []);
73
99
 
74
- const delta = row.riskDelta;
75
- if (!Number.isFinite(delta)) continue;
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;
76
104
 
77
- if (Math.abs(delta) < THRESHOLD) {
78
- continue;
105
+ assetAlerts.push({
106
+ type: 'ASSET_AUM',
107
+ symbol: item.symbol,
108
+ amount: Number(amount.toFixed(2)),
109
+ asOfDate: date
110
+ });
79
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);
80
132
 
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'
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)
89
145
  });
90
- }
146
+ });
91
147
 
92
- ctx.log(
93
- `[Sample_RiskSpikeAlerts_Grade3] generated ${alerts.length} alert(s) for ${ctx.date} with threshold ${THRESHOLD}`
94
- );
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
+ }
95
153
 
96
154
  return {
97
- outcome: alerts.length === 0 ? 'NO_ALERTS' : 'OK',
98
- asOfDate: ctx.date,
99
- alerts
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
100
160
  };
101
161
  };
102
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.996",
3
+ "version": "1.0.997",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [