bulltrackers-module 1.0.431 → 1.0.433

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,8 +1,6 @@
1
1
  /**
2
2
  * @fileoverview Handles saving computation results with observability and Smart Cleanup.
3
- * UPDATED: Tracks specific Firestore Ops (Writes/Deletes) for cost analysis.
4
- * UPDATED: Added Dynamic Circuit Breaker Bypass for Price-Only calculations.
5
- * UPDATED: Integrated Alert System Pub/Sub triggers for marked computations.
3
+ * UPDATED: Fixed bug where Alert Computations failed to trigger Pub/Sub on empty FINAL flush.
6
4
  */
7
5
  const { commitBatchInChunks, generateDataHash } = require('../utils/utils');
8
6
  const { updateComputationStatus } = require('./StatusRepository');
@@ -24,7 +22,7 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
24
22
  const failureReport = [];
25
23
  const schemas = [];
26
24
  const cleanupTasks = [];
27
- const alertTriggers = []; // [NEW] Collect alert triggers
25
+ const alertTriggers = [];
28
26
  const { logger, db } = deps;
29
27
  const pid = generateProcessId(PROCESS_TYPES.STORAGE, passName, dStr);
30
28
 
@@ -35,7 +33,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
35
33
  const fanOutLimit = pLimit(10);
36
34
  const pubSubUtils = new PubSubUtils(deps);
37
35
 
38
- // 1. [BATCH OPTIMIZATION] Fetch all SimHashes and Contracts upfront
39
36
  const calcNames = Object.keys(stateObj);
40
37
  const hashKeys = calcNames.map(n => stateObj[n].manifest?.hash).filter(Boolean);
41
38
 
@@ -56,11 +53,13 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
56
53
  io: { writes: 0, deletes: 0 }
57
54
  };
58
55
 
56
+ // [NEW] Check metadata for alert flag (defaults to false)
57
+ const isAlertComputation = calc.manifest.isAlertComputation === true;
58
+
59
59
  try {
60
60
  const result = await calc.getResult();
61
61
  const configOverrides = validationOverrides[calc.manifest.name] || {};
62
62
 
63
- // --- [DYNAMIC OVERRIDE LOGIC START] ---
64
63
  const dataDeps = calc.manifest.rootDataDependencies || [];
65
64
  const isPriceOnly = (dataDeps.length === 1 && dataDeps[0] === 'price');
66
65
  let effectiveOverrides = { ...configOverrides };
@@ -72,7 +71,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
72
71
  effectiveOverrides.maxNanPct = 100;
73
72
  delete effectiveOverrides.weekend;
74
73
  }
75
- // --- [DYNAMIC OVERRIDE LOGIC END] ---
76
74
 
77
75
  const contract = contractMap[name];
78
76
  if (contract) {
@@ -99,11 +97,27 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
99
97
 
100
98
  const isEmpty = !result || (typeof result === 'object' && Object.keys(result).length === 0);
101
99
  const resultHash = isEmpty ? 'empty' : generateDataHash(result);
102
-
103
100
  const simHash = (flushMode !== 'INTERMEDIATE') ? (simHashMap[calc.manifest.hash] || null) : null;
104
101
 
105
102
  if (isEmpty) {
106
- if (flushMode === 'INTERMEDIATE') { nextShardIndexes[name] = currentShardIndex; continue; }
103
+ if (flushMode === 'INTERMEDIATE') {
104
+ nextShardIndexes[name] = currentShardIndex;
105
+ continue;
106
+ }
107
+
108
+ // [FIX] Force alert trigger on FINAL flush even if result is empty
109
+ // This handles cases where all data was written in previous INTERMEDIATE flushes
110
+ if (isAlertComputation && flushMode === 'FINAL') {
111
+ // Reconstruct path to ensure downstream system checks for data
112
+ const docPath = `${config.resultsCollection}/${dStr}/${config.resultsSubcollection}/${calc.manifest.category}/${config.computationsSubcollection}/${name}`;
113
+
114
+ alertTriggers.push({
115
+ date: dStr,
116
+ computationName: name,
117
+ documentPath: docPath
118
+ });
119
+ }
120
+
107
121
  if (calc.manifest.hash) {
108
122
  successUpdates[name] = {
109
123
  hash: calc.manifest.hash, simHash: simHash, resultHash: resultHash,
@@ -119,9 +133,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
119
133
  const resultKeys = Object.keys(result || {});
120
134
  const isMultiDate = resultKeys.length > 0 && resultKeys.every(k => /^\d{4}-\d{2}-\d{2}$/.test(k));
121
135
 
122
- // [NEW] Check metadata for alert flag (defaults to false)
123
- const isAlertComputation = calc.manifest.isAlertComputation === true;
124
-
125
136
  if (isMultiDate) {
126
137
  const datePromises = resultKeys.map((historicalDate) => fanOutLimit(async () => {
127
138
  const dailyData = result[historicalDate];
@@ -131,7 +142,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
131
142
  runMetrics.io.writes += stats.opCounts.writes;
132
143
  runMetrics.io.deletes += stats.opCounts.deletes;
133
144
 
134
- // [NEW] Queue alert for historical date if applicable
135
145
  if (isAlertComputation && flushMode !== 'INTERMEDIATE') {
136
146
  alertTriggers.push({
137
147
  date: historicalDate,
@@ -156,7 +166,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
156
166
  nextShardIndexes[name] = writeStats.nextShardIndex;
157
167
  if (calc.manifest.hash) { successUpdates[name] = { hash: calc.manifest.hash, simHash, resultHash, dependencyResultHashes: calc.manifest.dependencyResultHashes || {}, category: calc.manifest.category, composition: calc.manifest.composition, metrics: runMetrics }; }
158
168
 
159
- // [NEW] Queue alert for standard write
160
169
  if (isAlertComputation && flushMode !== 'INTERMEDIATE') {
161
170
  alertTriggers.push({
162
171
  date: dStr,
@@ -193,7 +202,7 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
193
202
  await updateComputationStatus(dStr, successUpdates, config, deps);
194
203
  }
195
204
 
196
- // [NEW] Flush Alert Triggers to Pub/Sub
205
+ // Flush Alert Triggers to Pub/Sub
197
206
  if (alertTriggers.length > 0) {
198
207
  const topicName = config.alertTopicName || 'alert-trigger';
199
208
  try {
@@ -201,7 +210,6 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
201
210
  logger.log('INFO', `[Alert System] Triggered ${alertTriggers.length} alerts to ${topicName}`);
202
211
  } catch (alertErr) {
203
212
  logger.log('ERROR', `[Alert System] Failed to publish alerts: ${alertErr.message}`);
204
- // Non-blocking: We don't fail the computation if alerts fail to publish, but we log strictly.
205
213
  }
206
214
  }
207
215
 
@@ -0,0 +1,140 @@
1
+ # Data Feeder Pipeline
2
+ # Orchestrates data fetching relative to Market Close (22:00 UTC) and Midnight (00:00 UTC).
3
+ # Triggers at 22:00 UTC.
4
+
5
+ main:
6
+ params: [input]
7
+ steps:
8
+ - init:
9
+ assign:
10
+ - project: '${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}'
11
+ - location: "europe-west1"
12
+ - market_date: '${text.split(time.format(sys.now()), "T")[0]}'
13
+
14
+ # --- PHASE 1: MARKET CLOSE (22:00 UTC) ---
15
+ - run_market_close_tasks:
16
+ parallel:
17
+ branches:
18
+ - price_fetch:
19
+ steps:
20
+ - call_price_fetcher:
21
+ call: http.post
22
+ args:
23
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/price-fetcher"}'
24
+ auth: { type: OIDC }
25
+ - insights_fetch:
26
+ steps:
27
+ - call_insights_fetcher:
28
+ call: http.post
29
+ args:
30
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/insights-fetcher"}'
31
+ auth: { type: OIDC }
32
+
33
+ # [IMMEDIATE INDEX: MARKET DATA]
34
+ # Update index for the market date we just fetched
35
+ - index_market_data:
36
+ call: http.post
37
+ args:
38
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
39
+ body:
40
+ targetDate: '${market_date}'
41
+ auth: { type: OIDC }
42
+
43
+ # --- PHASE 2: WAIT FOR MIDNIGHT ---
44
+ # Trigger is at 22:00. We wait ~2 hours to align with 00:00 UTC.
45
+ - wait_for_midnight:
46
+ call: sys.sleep
47
+ args:
48
+ seconds: 7200 # 2 Hours
49
+
50
+ # --- PHASE 3: RANKINGS & INITIAL SOCIAL (00:00 UTC) ---
51
+ # Rankings must run first.
52
+ - run_rankings_fetch:
53
+ try:
54
+ call: http.post
55
+ args:
56
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/fetch-popular-investors"}'
57
+ auth: { type: OIDC }
58
+ except:
59
+ as: e
60
+ steps:
61
+ - log_rankings_error:
62
+ call: sys.log
63
+ args:
64
+ severity: "ERROR"
65
+ text: '${"Rankings Fetch Failed. Proceeding to Social Fetch. Error: " + json.encode(e)}'
66
+
67
+ - run_social_midnight:
68
+ call: http.post
69
+ args:
70
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/social-orchestrator"}'
71
+ auth: { type: OIDC }
72
+
73
+ # [IMMEDIATE INDEX: MIDNIGHT DATA]
74
+ # We recalculate the date because we are now past midnight
75
+ - index_midnight_data:
76
+ assign:
77
+ - current_date: '${text.split(time.format(sys.now()), "T")[0]}'
78
+ next: call_index_midnight
79
+
80
+ - call_index_midnight:
81
+ call: http.post
82
+ args:
83
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
84
+ body:
85
+ targetDate: '${current_date}'
86
+ auth: { type: OIDC }
87
+
88
+ # --- PHASE 4: GLOBAL SYNC (Before 01:00 UTC) ---
89
+ # Critical: Indexes EVERYTHING (no targetDate) to ensure the Computation System
90
+ # (which starts at 01:00 UTC) has a fully consistent view of history.
91
+ - run_global_indexer:
92
+ call: http.post
93
+ args:
94
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
95
+ # No body = Full Scan
96
+ auth: { type: OIDC }
97
+ result: global_index_res
98
+
99
+ # --- PHASE 5: RECURRING SOCIAL FETCH (Every 3 Hours) ---
100
+ # We loop to cover the rest of the 24h cycle.
101
+ - init_social_loop:
102
+ assign:
103
+ - i: 0
104
+
105
+ - social_loop:
106
+ switch:
107
+ - condition: ${i < 7}
108
+ steps:
109
+ - wait_3_hours:
110
+ call: sys.sleep
111
+ args:
112
+ seconds: 10800 # 3 hours
113
+
114
+ - run_social_recurring:
115
+ call: http.post
116
+ args:
117
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/social-orchestrator"}'
118
+ auth: { type: OIDC }
119
+
120
+ # [IMMEDIATE INDEX: RECURRING]
121
+ - calculate_recurring_date:
122
+ assign:
123
+ - current_date_rec: '${text.split(time.format(sys.now()), "T")[0]}'
124
+
125
+ - index_recurring:
126
+ call: http.post
127
+ args:
128
+ url: '${"https://" + location + "-" + project + ".cloudfunctions.net/root-data-indexer"}'
129
+ body:
130
+ targetDate: '${current_date_rec}'
131
+ auth: { type: OIDC }
132
+
133
+ - increment:
134
+ assign:
135
+ - i: ${i + 1}
136
+ - next_iteration:
137
+ next: social_loop
138
+
139
+ - finish:
140
+ return: "Daily Data Pipeline Completed"
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Root Data Indexer
3
3
  * Runs daily to index exactly what data is available for every date.
4
+ * UPDATED: Includes 'targetDate' optimization for fast single-day updates.
4
5
  * UPDATED: Includes explicit checks for Signed-In User Portfolios, History, AND Social Data.
5
- * FIXED: 'hasSocial' is now strictly for Generic Asset Data. PI/User data is separated.
6
- * FIXED: PI Collection paths now match the '19M/snapshots' structure.
7
6
  */
8
7
 
9
8
  const { FieldValue } = require('@google-cloud/firestore');
@@ -63,7 +62,8 @@ exports.runRootDataIndexer = async (config, dependencies) => {
63
62
  const {
64
63
  availabilityCollection,
65
64
  earliestDate,
66
- collections
65
+ collections,
66
+ targetDate // [NEW] Optional parameter to scan a single specific date
67
67
  } = config;
68
68
 
69
69
  const PRICE_COLLECTION_NAME = 'asset_prices';
@@ -72,45 +72,56 @@ exports.runRootDataIndexer = async (config, dependencies) => {
72
72
  const PI_SOCIAL_COLL_NAME = collections.piSocial || 'pi_social_posts';
73
73
  const SIGNED_IN_SOCIAL_COLL_NAME = collections.signedInUserSocialCollection || 'signed_in_users';
74
74
 
75
- logger.log('INFO', '[RootDataIndexer] Starting Root Data Availability Scan...');
75
+ const scanMode = targetDate ? 'SINGLE_DATE' : 'FULL_SCAN';
76
+ logger.log('INFO', `[RootDataIndexer] Starting Root Data Availability Scan... Mode: ${scanMode}`, { targetDate });
76
77
 
77
- // 1. Price Availability
78
- // Sample up to 10 shards to extract date keys (efficient - doesn't read all shards)
78
+ // 1. Price Availability (Global Scan Mode Only)
79
+ // If running for a single targetDate, we skip the global shard sampling to save time.
79
80
  const priceAvailabilitySet = new Set();
80
- try {
81
- // Path: asset_prices/shard_*
82
- const priceCollectionRef = db.collection(PRICE_COLLECTION_NAME);
83
- const priceShardsSnapshot = await priceCollectionRef.limit(10).get();
84
-
85
- if (!priceShardsSnapshot.empty) {
86
- // Sample up to 10 shards and extract date keys from them
87
- // This gives us a representative sample without reading hundreds of shards
88
- for (const shardDoc of priceShardsSnapshot.docs) {
89
- if (shardDoc.id.startsWith('shard_')) {
90
- const data = shardDoc.data();
91
- Object.values(data).forEach(instrument => {
92
- if (instrument && instrument.prices) {
93
- Object.keys(instrument.prices).forEach(dateKey => {
94
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) {
95
- priceAvailabilitySet.add(dateKey);
96
- }
97
- });
98
- }
99
- });
81
+
82
+ if (!targetDate) {
83
+ try {
84
+ // Path: asset_prices/shard_*
85
+ const priceCollectionRef = db.collection(PRICE_COLLECTION_NAME);
86
+ const priceShardsSnapshot = await priceCollectionRef.limit(10).get();
87
+
88
+ if (!priceShardsSnapshot.empty) {
89
+ // Sample up to 10 shards and extract date keys from them
90
+ for (const shardDoc of priceShardsSnapshot.docs) {
91
+ if (shardDoc.id.startsWith('shard_')) {
92
+ const data = shardDoc.data();
93
+ Object.values(data).forEach(instrument => {
94
+ if (instrument && instrument.prices) {
95
+ Object.keys(instrument.prices).forEach(dateKey => {
96
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) {
97
+ priceAvailabilitySet.add(dateKey);
98
+ }
99
+ });
100
+ }
101
+ });
102
+ }
100
103
  }
101
104
  }
105
+ } catch (e) {
106
+ logger.log('ERROR', '[RootDataIndexer] Failed to sample price shards.', { error: e.message });
102
107
  }
103
- } catch (e) {
104
- logger.log('ERROR', '[RootDataIndexer] Failed to sample price shards.', { error: e.message });
105
108
  }
106
109
 
107
110
  // 2. Determine Date Range
108
- const start = new Date(earliestDate || '2023-01-01');
109
- const end = new Date();
110
- end.setDate(end.getDate() + 1);
111
-
112
111
  const datesToScan = [];
113
- for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { datesToScan.push(d.toISOString().slice(0, 10)); }
112
+ if (targetDate) {
113
+ // [NEW] Single Date Optimization
114
+ datesToScan.push(targetDate);
115
+ } else {
116
+ // [OLD] Full History
117
+ const start = new Date(earliestDate || '2023-01-01');
118
+ const end = new Date();
119
+ end.setDate(end.getDate() + 1);
120
+
121
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
122
+ datesToScan.push(d.toISOString().slice(0, 10));
123
+ }
124
+ }
114
125
 
115
126
  // 3. Scan in Parallel
116
127
  const limit = pLimit(20);
@@ -211,9 +222,9 @@ exports.runRootDataIndexer = async (config, dependencies) => {
211
222
  normHistExists, specHistExists,
212
223
  insightsSnap, socialQuerySnap,
213
224
  piRankingsSnap,
214
- piPortExists, // UPDATED: Check for any part_* document
215
- piDeepExists, // UPDATED: Check for any part_* document
216
- piHistExists, // UPDATED: Check for any part_* document
225
+ piPortExists,
226
+ piDeepExists,
227
+ piHistExists,
217
228
  signedInPortExists, signedInHistExists,
218
229
  verificationsQuery,
219
230
  universalSocialSnap
@@ -253,7 +264,6 @@ exports.runRootDataIndexer = async (config, dependencies) => {
253
264
  availability.details.speculatorHistory = specHistExists;
254
265
  availability.details.piRankings = piRankingsSnap.exists;
255
266
 
256
- // UPDATED: Now checking for any part_* document existence
257
267
  availability.details.piPortfolios = piPortExists;
258
268
  availability.details.piDeepPortfolios = piDeepExists;
259
269
  availability.details.piHistory = piHistExists;
@@ -270,17 +280,24 @@ exports.runRootDataIndexer = async (config, dependencies) => {
270
280
  availability.details.signedInUserVerification = !verificationsQuery.empty;
271
281
 
272
282
  // Aggregates
273
- // UPDATED: Using part existence checks
274
283
  availability.hasPortfolio = normPortExists || specPortExists || piPortExists || signedInPortExists;
275
284
  availability.hasHistory = normHistExists || specHistExists || piHistExists || signedInHistExists;
276
285
  availability.hasInsights = insightsSnap.exists;
277
-
278
- // [CRITICAL FIX]
279
- // hasSocial strictly refers to GENERIC/ASSET social data.
280
- // It does NOT include PI or SignedIn data (they have their own flags).
281
286
  availability.hasSocial = !socialQuerySnap.empty;
282
287
 
283
- availability.hasPrices = priceAvailabilitySet.has(dateStr);
288
+ // Price Check
289
+ if (targetDate) {
290
+ // In single-date mode, we do a quick check on the shard if we don't have the full set loaded
291
+ // For performance, we default hasPrices to false unless we implement a specific single-date shard check.
292
+ // However, since this is usually called right after fetching, we can leave it as a secondary concern
293
+ // or assume the Global Sync will catch it later.
294
+ // OPTIONAL: Implementation of specific shard check for single date:
295
+ // For now, we reuse the set if available (it won't be) or default false.
296
+ // NOTE: The Global Scan at 01:00 UTC will strictly verify prices.
297
+ availability.hasPrices = priceAvailabilitySet.has(dateStr);
298
+ } else {
299
+ availability.hasPrices = priceAvailabilitySet.has(dateStr);
300
+ }
284
301
 
285
302
  await db.collection(availabilityCollection).doc(dateStr).set(availability);
286
303
  updatesCount++;
@@ -288,14 +305,12 @@ exports.runRootDataIndexer = async (config, dependencies) => {
288
305
  } catch (e) {
289
306
  logger.log('ERROR', `[RootDataIndexer] Failed to index ${dateStr}`, {
290
307
  message: e.message,
291
- code: e.code,
292
- stack: e.stack,
293
- full: JSON.stringify(e, Object.getOwnPropertyNames(e)),
308
+ code: e.code
294
309
  });
295
310
  }
296
311
  }));
297
312
 
298
313
  await Promise.all(promises);
299
- logger.log('SUCCESS', `[RootDataIndexer] Indexing complete. Updated ${updatesCount} dates.`);
300
- return { success: true, count: updatesCount };
314
+ logger.log('SUCCESS', `[RootDataIndexer] Indexing complete. Updated ${updatesCount} dates. Mode: ${scanMode}`);
315
+ return { success: true, count: updatesCount, mode: scanMode };
301
316
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.431",
3
+ "version": "1.0.433",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [