bulltrackers-module 1.0.709 → 1.0.712

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.
@@ -5,6 +5,7 @@ const { dispatchSyncRequest } = require('../task_engine_helper.js');
5
5
  const { sanitizeCid, sanitizeDocId } = require('../security_utils.js');
6
6
  const crypto = require('crypto');
7
7
  const zlib = require('zlib');
8
+ const { query: bigqueryQuery } = require('../../../core/utils/bigquery_utils');
8
9
 
9
10
  const storage = new Storage(); // Singleton GCS Client
10
11
 
@@ -3027,27 +3028,23 @@ const getWatchlistTriggerCounts = async (db, userId, watchlistId) => {
3027
3028
  };
3028
3029
 
3029
3030
  /**
3030
- * Query PIs matching dynamic watchlist criteria
3031
- *
3032
- * IMPORTANT: This function only evaluates PIs based on the MOST RECENT available data.
3033
- * The timeRange parameter controls how far back we look to FIND data (in case today's
3034
- * computation hasn't run yet), but we DO NOT aggregate matches across multiple dates.
3035
- *
3036
- * If a PI matched criteria 3 days ago but doesn't match on the most recent data,
3037
- * they will NOT be included - they "dropped off" the watchlist.
3038
- *
3039
- * @param {Object} db - Firestore instance
3031
+ * Query PIs matching dynamic watchlist criteria over a time range.
3032
+ * UPDATED LOGIC:
3033
+ * - Scans the entire requested time range (e.g. 7 days).
3034
+ * - Tracks history of matches vs non-matches.
3035
+ * - droppedOffAt is now an ARRAY of dates where the user stopped matching.
3036
+ * - Handles users disappearing from the dataset as a "drop-off" event.
3037
+ * * @param {Object} db - Firestore instance
3040
3038
  * @param {string} computationName - Name of the computation to query
3041
- * @param {Object} parameters - Threshold parameters from watchlist config (e.g., {minChange: 1, minRiskLevel: 5})
3042
- * @param {string} timeRange - Time range to look back for data availability (today, last_7_days, last_30_days)
3039
+ * @param {Object} parameters - Threshold parameters (e.g., {minChange: 1})
3040
+ * @param {string} timeRange - Time range (today, last_7_days, last_30_days)
3043
3041
  * @param {number} limit - Maximum number of results
3044
- * @returns {Promise<Object>} Matching PIs with their values
3045
3042
  */
3046
3043
  const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}, timeRange = 'last_7_days', limit = 100) => {
3047
3044
  try {
3048
- console.log(`[queryDynamicWatchlistMatches] Querying ${computationName} with params:`, parameters, `timeRange: ${timeRange}`);
3045
+ console.log(`[queryDynamicWatchlistMatches] Aggregating ${computationName} over ${timeRange}`);
3049
3046
 
3050
- // Determine how far back to look for data availability
3047
+ // 1. Determine Date Range
3051
3048
  const endDate = new Date();
3052
3049
  const startDate = new Date();
3053
3050
 
@@ -3055,42 +3052,59 @@ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}
3055
3052
  case 'today':
3056
3053
  // Just today
3057
3054
  break;
3058
- case 'last_7_days':
3059
- startDate.setDate(startDate.getDate() - 7);
3060
- break;
3061
3055
  case 'last_30_days':
3062
3056
  startDate.setDate(startDate.getDate() - 30);
3063
3057
  break;
3058
+ case 'last_7_days':
3064
3059
  default:
3065
3060
  startDate.setDate(startDate.getDate() - 7);
3066
3061
  }
3067
3062
 
3068
- // Build list of dates to check (most recent first)
3063
+ const startDateStr = startDate.toISOString().split('T')[0];
3064
+ const endDateStr = endDate.toISOString().split('T')[0];
3065
+
3066
+ // 2. Try BigQuery first (if enabled)
3067
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
3068
+ try {
3069
+ const bigqueryResult = await queryDynamicWatchlistMatchesBigQuery(
3070
+ computationName,
3071
+ parameters,
3072
+ startDateStr,
3073
+ endDateStr,
3074
+ limit,
3075
+ db
3076
+ );
3077
+ if (bigqueryResult) {
3078
+ console.log(`[queryDynamicWatchlistMatches] Successfully queried from BigQuery`);
3079
+ return bigqueryResult;
3080
+ }
3081
+ } catch (bqError) {
3082
+ console.warn(`[queryDynamicWatchlistMatches] BigQuery query failed, falling back to Firestore: ${bqError.message}`);
3083
+ // Fall through to Firestore logic
3084
+ }
3085
+ }
3086
+
3087
+ // 3. Fallback to Firestore (original logic)
3088
+ // Build list of dates to check (Newest -> Oldest)
3069
3089
  const dates = [];
3070
3090
  for (let d = new Date(endDate); d >= startDate; d.setDate(d.getDate() - 1)) {
3071
3091
  dates.push(d.toISOString().split('T')[0]);
3072
3092
  }
3073
3093
 
3074
- console.log(`[queryDynamicWatchlistMatches] Looking for most recent data in date range:`, dates);
3075
-
3076
- // Find the MOST RECENT date that has computation data
3077
- let mostRecentDate = null;
3078
- let docRef = null;
3079
- let docSnapshot = null;
3080
-
3081
- for (const dateStr of dates) {
3094
+ // 4. Fetch Data for ALL Dates in Parallel
3095
+ const datePromises = dates.map(async (dateStr) => {
3082
3096
  try {
3083
- // Try alerts path first
3084
- docRef = db.collection('unified_insights')
3097
+ // Try alerts path first (primary location for alert computations)
3098
+ let docRef = db.collection('unified_insights')
3085
3099
  .doc(dateStr)
3086
3100
  .collection('results')
3087
3101
  .doc('alerts')
3088
3102
  .collection('computations')
3089
3103
  .doc(computationName);
3090
3104
 
3091
- docSnapshot = await docRef.get();
3105
+ let docSnapshot = await docRef.get();
3092
3106
 
3093
- // If not found, try popular-investor path
3107
+ // Fallback to popular-investor path
3094
3108
  if (!docSnapshot.exists) {
3095
3109
  docRef = db.collection('unified_insights')
3096
3110
  .doc(dateStr)
@@ -3098,138 +3112,206 @@ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}
3098
3112
  .doc('popular-investor')
3099
3113
  .collection('computations')
3100
3114
  .doc(computationName);
3101
-
3102
3115
  docSnapshot = await docRef.get();
3103
3116
  }
3104
-
3105
- if (docSnapshot.exists) {
3106
- mostRecentDate = dateStr;
3107
- console.log(`[queryDynamicWatchlistMatches] Found most recent data on ${dateStr}`);
3108
- break; // Stop searching - we found the most recent data
3117
+
3118
+ if (!docSnapshot.exists) return null;
3119
+
3120
+ const docData = docSnapshot.data();
3121
+ let dayData = {};
3122
+
3123
+ // Handle Sharding
3124
+ if (docData._sharded === true) {
3125
+ const shardsSnapshot = await docRef.collection('_shards').get();
3126
+ for (const shardDoc of shardsSnapshot.docs) {
3127
+ const shardContent = shardDoc.data();
3128
+ Object.entries(shardContent).forEach(([key, value]) => {
3129
+ if (!key.startsWith('_') && key !== 'cids' && /^\d+$/.test(key)) {
3130
+ dayData[key] = value;
3131
+ }
3132
+ });
3133
+ }
3134
+ } else {
3135
+ // Standard Document
3136
+ Object.entries(docData).forEach(([key, value]) => {
3137
+ if (!key.startsWith('_') && key !== 'cids' && /^\d+$/.test(key)) {
3138
+ dayData[key] = value;
3139
+ }
3140
+ });
3109
3141
  }
3110
- } catch (dateErr) {
3111
- console.warn(`[queryDynamicWatchlistMatches] Error checking date ${dateStr}: ${dateErr.message}`);
3112
- continue;
3142
+
3143
+ return { date: dateStr, data: dayData };
3144
+
3145
+ } catch (err) {
3146
+ console.warn(`[queryDynamicWatchlistMatches] Error fetching date ${dateStr}: ${err.message}`);
3147
+ return null;
3113
3148
  }
3114
- }
3115
-
3116
- // If no data found in the entire range, return empty
3117
- if (!mostRecentDate || !docSnapshot || !docSnapshot.exists) {
3118
- console.log(`[queryDynamicWatchlistMatches] No computation data found for ${computationName} in date range`);
3119
- return {
3120
- success: true,
3121
- matches: [],
3122
- count: 0,
3123
- totalScanned: 0,
3124
- dateRange: {
3125
- start: startDate.toISOString().split('T')[0],
3126
- end: endDate.toISOString().split('T')[0]
3127
- },
3128
- dataDate: null,
3129
- computationName,
3130
- parameters,
3131
- message: `No computation data found for ${computationName} in the selected time range`
3132
- };
3133
- }
3149
+ });
3150
+
3151
+ // Wait for all days to load
3152
+ // rawResults is sorted Newest -> Oldest (matches 'dates' order)
3153
+ const rawResults = (await Promise.all(datePromises)).filter(r => r !== null);
3134
3154
 
3135
- const docData = docSnapshot.data();
3155
+ // 5. Aggregate Matches Per User
3156
+ // Map: piCid -> { firstMatchedAt, lastMatchedAt, history: [], ... }
3157
+ const piAggregates = new Map();
3136
3158
 
3137
- // Read all per-user data from the most recent date
3138
- let allUserData = {};
3159
+ // Process dates from Oldest -> Newest to build timeline correctly
3160
+ // Note: .reverse() mutates the array in place, so rawResults becomes Oldest->Newest
3161
+ const timeline = rawResults.reverse();
3139
3162
 
3140
- if (docData._sharded === true) {
3141
- // Read from shards
3142
- const shardsSnapshot = await docRef.collection('_shards').get();
3143
- console.log(`[queryDynamicWatchlistMatches] Found ${shardsSnapshot.size} shards for ${mostRecentDate}`);
3163
+ for (const dayEntry of timeline) {
3164
+ const { date, data } = dayEntry;
3165
+ const seenCidsThisDay = new Set();
3144
3166
 
3145
- for (const shardDoc of shardsSnapshot.docs) {
3146
- const shardData = shardDoc.data();
3147
- Object.entries(shardData).forEach(([key, value]) => {
3148
- // Skip metadata keys, only include CID keys
3149
- if (key.startsWith('_') || key === 'cids' || key === 'metadata') return;
3150
- if (/^\d+$/.test(key)) {
3151
- allUserData[key] = value;
3167
+ // A. Process Users Present in the Daily File
3168
+ for (const [piCidStr, piData] of Object.entries(data)) {
3169
+ if (piData.error) continue;
3170
+
3171
+ const piCid = Number(piCidStr);
3172
+ seenCidsThisDay.add(piCid);
3173
+
3174
+ const filterResult = checkPIMatchesCriteria(computationName, piData, parameters);
3175
+
3176
+ if (filterResult.passes) {
3177
+ // Initialize if new
3178
+ if (!piAggregates.has(piCid)) {
3179
+ piAggregates.set(piCid, {
3180
+ cid: piCid,
3181
+ firstMatchedAt: date,
3182
+ lastMatchedAt: date,
3183
+ matchCount: 0,
3184
+ history: [],
3185
+ latestData: null
3186
+ });
3152
3187
  }
3153
- });
3188
+
3189
+ const agg = piAggregates.get(piCid);
3190
+ agg.lastMatchedAt = date;
3191
+ agg.matchCount++;
3192
+ agg.latestData = piData;
3193
+
3194
+ agg.history.push({
3195
+ date: date,
3196
+ matched: true,
3197
+ value: filterResult.matchValue,
3198
+ change: filterResult.change
3199
+ });
3200
+ } else {
3201
+ // User exists in data but DOES NOT match criteria
3202
+ if (piAggregates.has(piCid)) {
3203
+ const agg = piAggregates.get(piCid);
3204
+ // Update metadata to show why they failed (current value)
3205
+ agg.latestData = piData;
3206
+
3207
+ agg.history.push({
3208
+ date: date,
3209
+ matched: false,
3210
+ value: filterResult.matchValue,
3211
+ change: filterResult.change
3212
+ });
3213
+ }
3214
+ }
3154
3215
  }
3155
- } else {
3156
- // Data is in the document itself
3157
- Object.entries(docData).forEach(([key, value]) => {
3158
- if (key.startsWith('_') || key === 'cids' || key === 'metadata' || key === 'globalMetadata') return;
3159
- if (/^\d+$/.test(key)) {
3160
- allUserData[key] = value;
3216
+
3217
+ // B. Process Missing Users (Implicit Drop-off)
3218
+ // If a user was tracked previously but is missing today, record as non-match
3219
+ for (const [cid, agg] of piAggregates) {
3220
+ if (!seenCidsThisDay.has(cid)) {
3221
+ agg.history.push({
3222
+ date: date,
3223
+ matched: false,
3224
+ value: null, // Value unknown/missing
3225
+ change: null
3226
+ });
3161
3227
  }
3162
- });
3228
+ }
3163
3229
  }
3164
3230
 
3165
- const totalPIs = Object.keys(allUserData).length;
3166
- console.log(`[queryDynamicWatchlistMatches] Evaluating ${totalPIs} PIs from ${mostRecentDate} against criteria`);
3167
-
3168
- // Filter PIs that match the criteria on this most recent date
3169
- const matchingPIs = [];
3170
- const cidsToLookup = [];
3231
+ // 6. Calculate Status (Dropped Off, Current) & Fetch Usernames
3232
+ const results = [];
3233
+ const todayStr = new Date().toISOString().split('T')[0];
3234
+ // Since rawResults was reversed, the last element is the Newest date
3235
+ const lastDataDate = timeline.length > 0 ? timeline[timeline.length - 1].date : todayStr;
3171
3236
 
3172
- for (const [piCidStr, piData] of Object.entries(allUserData)) {
3173
- const piCid = Number(piCidStr);
3174
-
3175
- // Skip error data
3176
- if (piData.error) continue;
3237
+ for (const [cid, agg] of piAggregates) {
3238
+ const history = agg.history;
3239
+ const lastEntry = history[history.length - 1];
3177
3240
 
3178
- // Check if PI matches the criteria
3179
- const filterResult = checkPIMatchesCriteria(computationName, piData, parameters);
3241
+ // Is Currently Matching?
3242
+ // Must be matched=true AND on the most recent data date available
3243
+ const isCurrent = lastEntry.matched && lastEntry.date === lastDataDate;
3180
3244
 
3181
- if (filterResult.passes) {
3182
- cidsToLookup.push({ piCid, piData, filterResult });
3183
- }
3184
- }
3185
-
3186
- console.log(`[queryDynamicWatchlistMatches] ${cidsToLookup.length} PIs match criteria, fetching usernames...`);
3187
-
3188
- // Fetch usernames for matching PIs (batch for efficiency)
3189
- for (const { piCid, piData, filterResult } of cidsToLookup.slice(0, limit)) {
3190
- let username = `PI-${piCid}`;
3191
- try {
3192
- const piProfile = await fetchPopularInvestorMasterList(db, String(piCid));
3193
- if (piProfile && piProfile.username) {
3194
- username = piProfile.username;
3245
+ // Calculate Drop Off Dates
3246
+ // Find all transitions from True -> False
3247
+ const droppedOffAt = [];
3248
+ for (let i = 1; i < history.length; i++) {
3249
+ const prev = history[i - 1];
3250
+ const curr = history[i];
3251
+ if (prev.matched && !curr.matched) {
3252
+ droppedOffAt.push(curr.date);
3195
3253
  }
3196
- } catch (e) {
3197
- // Use default username
3198
3254
  }
3199
3255
 
3200
- matchingPIs.push({
3201
- cid: piCid,
3202
- username,
3203
- matchedAt: mostRecentDate,
3204
- matchValue: filterResult.matchValue,
3205
- currentValue: filterResult.currentValue,
3206
- previousValue: filterResult.previousValue,
3207
- change: filterResult.change,
3208
- metadata: {
3209
- ...piData,
3210
- computationDate: mostRecentDate
3211
- }
3256
+ // Fetch Username (Optimistic)
3257
+ let username = `PI-${cid}`;
3258
+ if (db) {
3259
+ try {
3260
+ const piProfile = await fetchPopularInvestorMasterList(db, String(cid)).catch(() => null);
3261
+ if (piProfile) username = piProfile.username;
3262
+ } catch (e) {}
3263
+ }
3264
+
3265
+ results.push({
3266
+ cid: cid,
3267
+ username: username,
3268
+
3269
+ // Aggregated Stats
3270
+ firstMatchedAt: agg.firstMatchedAt,
3271
+ lastMatchedAt: agg.lastMatchedAt,
3272
+
3273
+ // [UPDATED] Array of dates where they stopped matching
3274
+ droppedOffAt: droppedOffAt,
3275
+
3276
+ isCurrentlyMatching: isCurrent,
3277
+ matchCount: agg.matchCount,
3278
+
3279
+ // Visualization Data
3280
+ history: agg.history,
3281
+
3282
+ // Latest Snapshot Values
3283
+ latestValue: history[history.length - 1]?.value,
3284
+
3285
+ // Metadata
3286
+ metadata: agg.latestData
3212
3287
  });
3213
3288
  }
3214
3289
 
3215
- // Sort by match value (descending)
3216
- const sortedMatches = matchingPIs
3217
- .sort((a, b) => Math.abs(b.matchValue) - Math.abs(a.matchValue))
3218
- .slice(0, limit);
3290
+ // 7. Sort Results
3291
+ // Priority: Currently Matching > Recently Dropped Off
3292
+ // Secondary: Match Value magnitude
3293
+ results.sort((a, b) => {
3294
+ if (a.isCurrentlyMatching !== b.isCurrentlyMatching) {
3295
+ return a.isCurrentlyMatching ? -1 : 1;
3296
+ }
3297
+ // If both same status, sort by magnitude of value (risk, change, etc)
3298
+ return Math.abs(b.latestValue || 0) - Math.abs(a.latestValue || 0);
3299
+ });
3300
+
3301
+ const limitedResults = results.slice(0, limit);
3219
3302
 
3220
- console.log(`[queryDynamicWatchlistMatches] Returning ${sortedMatches.length} matches from ${mostRecentDate}`);
3303
+ console.log(`[queryDynamicWatchlistMatches] Found ${results.length} unique PIs matching at least once.`);
3221
3304
 
3222
3305
  return {
3223
3306
  success: true,
3224
- matches: sortedMatches,
3225
- count: sortedMatches.length,
3226
- totalScanned: totalPIs,
3227
- totalMatching: cidsToLookup.length,
3307
+ matches: limitedResults,
3308
+ count: limitedResults.length,
3309
+ totalUniqueMatches: results.length,
3228
3310
  dateRange: {
3229
- start: startDate.toISOString().split('T')[0],
3230
- end: endDate.toISOString().split('T')[0]
3311
+ start: startDateStr,
3312
+ end: endDateStr
3231
3313
  },
3232
- dataDate: mostRecentDate, // The actual date the data is from
3314
+ dataDate: lastDataDate,
3233
3315
  computationName,
3234
3316
  parameters
3235
3317
  };
@@ -189,7 +189,28 @@ class CachedDataLoader {
189
189
  return getRelevantShardRefs(this.config, this.deps, targetInstrumentIds);
190
190
  }
191
191
 
192
- async loadPriceShard(docRef) {
192
+ async loadPriceShard(docRef) {
193
+ // Check if this is a BigQuery marker
194
+ if (docRef && docRef._bigquery === true) {
195
+ // Load all price data from BigQuery
196
+ try {
197
+ const { queryAssetPrices } = require('../../core/utils/bigquery_utils');
198
+ const priceData = await queryAssetPrices(null, null, null, this.deps.logger);
199
+
200
+ if (priceData && Object.keys(priceData).length > 0) {
201
+ this.deps.logger.log('INFO', `[CachedDataLoader] ✅ Loaded ${Object.keys(priceData).length} instruments from BigQuery`);
202
+ return priceData;
203
+ }
204
+
205
+ // If BigQuery returns empty, fallback to Firestore
206
+ this.deps.logger.log('WARN', `[CachedDataLoader] BigQuery returned no price data, falling back to Firestore`);
207
+ } catch (bqError) {
208
+ this.deps.logger.log('WARN', `[CachedDataLoader] BigQuery price load failed, falling back to Firestore: ${bqError.message}`);
209
+ // Fall through to Firestore
210
+ }
211
+ }
212
+
213
+ // Firestore fallback (original logic)
193
214
  try {
194
215
  const snap = await docRef.get();
195
216
  return snap.exists ? this._tryDecompress(snap.data()) : {};
@@ -44,11 +44,36 @@ function tryDecompress(payload) {
44
44
 
45
45
  /**
46
46
  * Fetches, decompresses, and reassembles (if sharded or on GCS) a single result document.
47
+ * NEW: For non-alert, non-page computations, tries BigQuery first (cheaper, no sharding/compression).
47
48
  */
48
49
  async function fetchSingleResult(db, config, dateStr, name, category) {
49
50
  const { resultsCollection = 'computation_results', resultsSubcollection = 'results', computationsSubcollection = 'computations' } = config;
50
51
  const log = config.logger || console;
51
52
 
53
+ // NEW STRATEGY: Check if this is an alert or page computation
54
+ // We need to check the manifest to determine this, but we can infer from category
55
+ // For now, we'll try BigQuery first for all non-alert computations (alerts are in 'alerts' category)
56
+ const isAlertComputation = category === 'alerts';
57
+ // Page computations are typically in 'popular-investor' category but have isPage flag
58
+ // For now, we'll try BigQuery for all non-alert computations
59
+
60
+ // Try BigQuery first for non-alert computations (reduces Firestore reads)
61
+ if (!isAlertComputation && process.env.BIGQUERY_ENABLED !== 'false') {
62
+ try {
63
+ const { queryComputationResult } = require('../../core/utils/bigquery_utils');
64
+ const bigqueryResult = await queryComputationResult(name, category, dateStr, log);
65
+
66
+ if (bigqueryResult && !isDataEmpty(bigqueryResult)) {
67
+ log.log('INFO', `[DependencyFetcher] ✅ Using BigQuery for ${name} (${dateStr}, ${category})`);
68
+ return bigqueryResult;
69
+ }
70
+ } catch (bqError) {
71
+ log.log('WARN', `[DependencyFetcher] BigQuery fetch failed for ${name}, falling back to Firestore: ${bqError.message}`);
72
+ // Fall through to Firestore
73
+ }
74
+ }
75
+
76
+ // Fallback to Firestore (for alerts, pages, or if BigQuery fails)
52
77
  const docRef = db.collection(resultsCollection).doc(dateStr)
53
78
  .collection(resultsSubcollection).doc(category)
54
79
  .collection(computationsSubcollection).doc(name);
@@ -238,6 +263,9 @@ async function fetchResultSeries(endDateStr, calcNames, manifestLookup, config,
238
263
  d.setUTCDate(d.getUTCDate() - 1);
239
264
  dates.push(d.toISOString().slice(0, 10));
240
265
  }
266
+
267
+ const startDateStr = dates[dates.length - 1]; // Oldest date
268
+ const queryEndDateStr = dates[0]; // Newest date (for BigQuery query)
241
269
 
242
270
  // [DEBUG] Log the manifest lookup and resolved categories
243
271
  logger.log('INFO', `[DependencyFetcher] 🔍 ManifestLookup has ${Object.keys(manifestLookup).length} entries`);
@@ -248,6 +276,96 @@ async function fetchResultSeries(endDateStr, calcNames, manifestLookup, config,
248
276
  logger.log('INFO', `[DependencyFetcher] 📍 '${rawName}' -> category='${category}' -> Path: ${samplePath}`);
249
277
  }
250
278
 
279
+ // =========================================================================
280
+ // BIGQUERY FIRST: Try batch query for all dates at once
281
+ // =========================================================================
282
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
283
+ try {
284
+ const { queryComputationResultsRange } = require('../../core/utils/bigquery_utils');
285
+
286
+ // Query each computation in parallel
287
+ const bigqueryPromises = calcNames.map(async (rawName) => {
288
+ const norm = normalizeName(rawName);
289
+ const category = manifestLookup[norm] || 'analytics';
290
+
291
+ const bigqueryRows = await queryComputationResultsRange(
292
+ rawName,
293
+ category,
294
+ startDateStr,
295
+ queryEndDateStr,
296
+ logger
297
+ );
298
+
299
+ if (bigqueryRows && bigqueryRows.length > 0) {
300
+ logger.log('INFO', `[DependencyFetcher] ✅ Using BigQuery for ${rawName} series: ${bigqueryRows.length} dates`);
301
+
302
+ // Map BigQuery results to results structure
303
+ for (const row of bigqueryRows) {
304
+ if (row.data && !isDataEmpty(row.data)) {
305
+ results[norm][row.date] = row.data;
306
+ }
307
+ }
308
+
309
+ return { name: rawName, found: bigqueryRows.length };
310
+ }
311
+
312
+ return { name: rawName, found: 0 };
313
+ });
314
+
315
+ const bigqueryResults = await Promise.all(bigqueryPromises);
316
+ const totalFound = bigqueryResults.reduce((sum, r) => sum + r.found, 0);
317
+
318
+ if (totalFound > 0) {
319
+ logger.log('INFO', `[DependencyFetcher] ✅ BigQuery retrieved ${totalFound} computation result records across ${calcNames.length} computations`);
320
+
321
+ // Fill in any missing dates from Firestore (fallback)
322
+ const missingOps = [];
323
+ for (const dateStr of dates) {
324
+ for (const rawName of calcNames) {
325
+ const norm = normalizeName(rawName);
326
+ // Only fetch if we don't have this date already
327
+ if (!results[norm] || !results[norm][dateStr]) {
328
+ const category = manifestLookup[norm] || 'analytics';
329
+ missingOps.push(async () => {
330
+ const val = await fetchSingleResult(db, { ...config, logger }, dateStr, rawName, category);
331
+ if (val && !isDataEmpty(val)) {
332
+ results[norm][dateStr] = val;
333
+ }
334
+ });
335
+ }
336
+ }
337
+ }
338
+
339
+ // Fetch missing dates from Firestore
340
+ if (missingOps.length > 0) {
341
+ logger.log('INFO', `[DependencyFetcher] 📂 Fetching ${missingOps.length} missing dates from Firestore (fallback)`);
342
+ const BATCH_SIZE = 20;
343
+ for (let i = 0; i < missingOps.length; i += BATCH_SIZE) {
344
+ await Promise.all(missingOps.slice(i, i + BATCH_SIZE).map(fn => fn()));
345
+ }
346
+ }
347
+
348
+ // Log final summary
349
+ for (const rawName of calcNames) {
350
+ const norm = normalizeName(rawName);
351
+ const foundDates = Object.keys(results[norm] || {});
352
+ logger.log('INFO', `[DependencyFetcher] ✅ '${rawName}' found data for ${foundDates.length}/${lookbackDays} days (BigQuery + Firestore)`);
353
+ }
354
+
355
+ return results;
356
+ } else {
357
+ logger.log('INFO', `[DependencyFetcher] ⚠️ BigQuery returned no results, falling back to Firestore`);
358
+ }
359
+ } catch (bqError) {
360
+ logger.log('WARN', `[DependencyFetcher] BigQuery series query failed, falling back to Firestore: ${bqError.message}`);
361
+ }
362
+ }
363
+
364
+ // =========================================================================
365
+ // FIRESTORE FALLBACK: Original logic (backwards compatibility)
366
+ // =========================================================================
367
+ logger.log('INFO', `[DependencyFetcher] 📂 Using Firestore for computation result series: ${calcNames.length} calcs x ${lookbackDays} days`);
368
+
251
369
  // Build Fetch Operations
252
370
  const ops = [];
253
371
  for (const dateStr of dates) {