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.
@@ -281,8 +281,8 @@ function needsUpdate(lastUpdated, todayStr) {
281
281
 
282
282
  /**
283
283
  * [NEW] Fetches Popular Investors from the master list and filters by last updated times.
284
- * UPDATED: Uses the master list as single source of truth, then checks last updated timestamps
285
- * for each data type (portfolio, tradeHistory, socialPosts) against TODAY to determine who needs updating.
284
+ * UPDATED: Uses BigQuery first for master list, then checks BigQuery for today's data to filter users.
285
+ * Falls back to Firestore if BigQuery is disabled or fails.
286
286
  */
287
287
  async function getPopularInvestorsToUpdate(dependencies, config) {
288
288
  const { db, logger, collectionRegistry } = dependencies;
@@ -291,53 +291,122 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
291
291
  logger.log('INFO', `[Core Utils] Getting Popular Investors to update (Checking against date: ${todayStr})...`);
292
292
 
293
293
  try {
294
- // Get the master list of Popular Investors
295
- let masterListPath = 'system_state/popular_investor_master_list';
294
+ let investors = {};
295
+ let masterListSource = 'UNKNOWN';
296
296
 
297
- if (collectionRegistry && collectionRegistry.getCollectionPath) {
297
+ // =========================================================================
298
+ // BIGQUERY FIRST: Try BigQuery for master list
299
+ // =========================================================================
300
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
298
301
  try {
299
- masterListPath = collectionRegistry.getCollectionPath('system', 'popularInvestorMasterList', {});
300
- } catch (err) {
301
- logger.log('WARN', `[Core Utils] Failed to get master list path from registry, using default: ${err.message}`);
302
+ const { queryPIMasterList } = require('../../core/utils/bigquery_utils');
303
+ const bigqueryMasterList = await queryPIMasterList(logger);
304
+
305
+ if (bigqueryMasterList && Object.keys(bigqueryMasterList).length > 0) {
306
+ investors = bigqueryMasterList;
307
+ masterListSource = 'BIGQUERY';
308
+ logger.log('INFO', `[Core Utils] ✅ Loaded PI master list from BigQuery: ${Object.keys(investors).length} investors`);
309
+ }
310
+ } catch (bqError) {
311
+ logger.log('WARN', `[Core Utils] BigQuery master list query failed, falling back to Firestore: ${bqError.message}`);
302
312
  }
303
313
  }
304
314
 
305
- const masterListRef = db.doc(masterListPath);
306
- const masterListDoc = await masterListRef.get();
307
-
308
- if (!masterListDoc.exists) {
309
- logger.log('WARN', `[Core Utils] Master list not found. Falling back to legacy method.`);
310
- return await getPopularInvestorsToUpdateLegacy(dependencies, config);
315
+ // =========================================================================
316
+ // FIRESTORE FALLBACK: If BigQuery didn't return data
317
+ // =========================================================================
318
+ if (Object.keys(investors).length === 0) {
319
+ let masterListPath = 'system_state/popular_investor_master_list';
320
+
321
+ if (collectionRegistry && collectionRegistry.getCollectionPath) {
322
+ try {
323
+ masterListPath = collectionRegistry.getCollectionPath('system', 'popularInvestorMasterList', {});
324
+ } catch (err) {
325
+ logger.log('WARN', `[Core Utils] Failed to get master list path from registry, using default: ${err.message}`);
326
+ }
327
+ }
328
+
329
+ const masterListRef = db.doc(masterListPath);
330
+ const masterListDoc = await masterListRef.get();
331
+
332
+ if (!masterListDoc.exists) {
333
+ logger.log('WARN', `[Core Utils] Master list not found in Firestore. Falling back to legacy method.`);
334
+ return await getPopularInvestorsToUpdateLegacy(dependencies, config);
335
+ }
336
+
337
+ const masterListData = masterListDoc.data();
338
+ investors = masterListData.investors || {};
339
+ masterListSource = 'FIRESTORE';
340
+ logger.log('INFO', `[Core Utils] Loaded PI master list from Firestore: ${Object.keys(investors).length} investors`);
311
341
  }
312
342
 
313
- const masterListData = masterListDoc.data();
314
- const investors = masterListData.investors || {};
315
-
316
343
  if (Object.keys(investors).length === 0) {
317
344
  logger.log('WARN', `[Core Utils] Master list is empty. Returning empty array.`);
318
345
  return [];
319
346
  }
320
347
 
348
+ // =========================================================================
349
+ // FILTER: Check BigQuery for today's data to determine who needs updating
350
+ // =========================================================================
321
351
  const targets = [];
322
352
  let skippedCount = 0;
323
353
 
354
+ // Get today's data from BigQuery to check who's already updated
355
+ let todayPortfolioUsers = new Set();
356
+ let todayHistoryUsers = new Set();
357
+ let todaySocialUsers = new Set();
358
+
359
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
360
+ try {
361
+ const { queryPortfolioData, queryHistoryData, querySocialData } = require('../../core/utils/bigquery_utils');
362
+
363
+ // Query BigQuery for today's data (only for POPULAR_INVESTOR user type)
364
+ const [portfolioData, historyData, socialData] = await Promise.all([
365
+ queryPortfolioData(todayStr, null, ['POPULAR_INVESTOR'], logger).catch(() => null),
366
+ queryHistoryData(todayStr, null, ['POPULAR_INVESTOR'], logger).catch(() => null),
367
+ querySocialData(todayStr, null, ['POPULAR_INVESTOR'], logger).catch(() => null)
368
+ ]);
369
+
370
+ if (portfolioData) {
371
+ todayPortfolioUsers = new Set(Object.keys(portfolioData));
372
+ }
373
+ if (historyData) {
374
+ todayHistoryUsers = new Set(Object.keys(historyData));
375
+ }
376
+ if (socialData) {
377
+ todaySocialUsers = new Set(Object.keys(socialData));
378
+ }
379
+
380
+ logger.log('INFO', `[Core Utils] BigQuery filter: ${todayPortfolioUsers.size} portfolios, ${todayHistoryUsers.size} histories, ${todaySocialUsers.size} social posts for ${todayStr}`);
381
+ } catch (bqError) {
382
+ logger.log('WARN', `[Core Utils] BigQuery filter query failed, using Firestore fallback: ${bqError.message}`);
383
+ }
384
+ }
385
+
386
+ // Process each investor from master list
324
387
  for (const [cid, piData] of Object.entries(investors)) {
325
388
  const username = piData.username || String(cid);
326
389
 
327
- // Construct path manually to avoid registry lookup errors on "profile"
328
- const piDocPath = `PopularInvestors/${cid}`;
329
- const piDoc = await db.doc(piDocPath).get();
330
-
331
- // Default: All need update
332
- let components = { portfolio: true, tradeHistory: true, socialPosts: true };
390
+ // Check if already updated today (BigQuery first, then Firestore)
391
+ let components = {
392
+ portfolio: !todayPortfolioUsers.has(cid),
393
+ tradeHistory: !todayHistoryUsers.has(cid),
394
+ socialPosts: !todaySocialUsers.has(cid)
395
+ };
333
396
 
334
- if (piDoc.exists) {
335
- const data = piDoc.data();
336
- const lastUpdated = data.lastUpdated || {};
397
+ // If BigQuery didn't have data, check Firestore
398
+ if (todayPortfolioUsers.size === 0 && todayHistoryUsers.size === 0 && todaySocialUsers.size === 0) {
399
+ const piDocPath = `PopularInvestors/${cid}`;
400
+ const piDoc = await db.doc(piDocPath).get();
337
401
 
338
- components.portfolio = needsUpdate(lastUpdated.portfolio, todayStr);
339
- components.tradeHistory = needsUpdate(lastUpdated.tradeHistory, todayStr);
340
- components.socialPosts = needsUpdate(lastUpdated.socialPosts, todayStr);
402
+ if (piDoc.exists) {
403
+ const data = piDoc.data();
404
+ const lastUpdated = data.lastUpdated || {};
405
+
406
+ components.portfolio = needsUpdate(lastUpdated.portfolio, todayStr);
407
+ components.tradeHistory = needsUpdate(lastUpdated.tradeHistory, todayStr);
408
+ components.socialPosts = needsUpdate(lastUpdated.socialPosts, todayStr);
409
+ }
341
410
  }
342
411
 
343
412
  // Only add if at least one component needs updating
@@ -348,7 +417,7 @@ async function getPopularInvestorsToUpdate(dependencies, config) {
348
417
  }
349
418
  }
350
419
 
351
- logger.log('INFO', `[Core Utils] Found ${Object.keys(investors).length} Popular Investors in master list. Skipped ${skippedCount} (updated today). ${targets.length} queued for update.`);
420
+ logger.log('INFO', `[Core Utils] Found ${Object.keys(investors).length} Popular Investors in master list (${masterListSource}). Skipped ${skippedCount} (updated today). ${targets.length} queued for update.`);
352
421
  return targets;
353
422
 
354
423
  } catch (error) {
@@ -16,11 +16,24 @@ const SHARD_SIZE = 40;
16
16
  * @returns {Promise<{success: boolean, message: string, instrumentsProcessed?: number}>}
17
17
  */
18
18
  exports.fetchAndStorePrices = async (config, dependencies) => {
19
- const { db, logger, headerManager, proxyManager, collectionRegistry } = dependencies;
19
+ const { db, logger, headerManager, proxyManager, collectionRegistry, calculationUtils } = dependencies;
20
20
  logger.log('INFO', '[PriceFetcherHelpers] Starting Daily Closing Price Update...');
21
21
  let selectedHeader = null;
22
22
  let wasSuccessful = false;
23
23
 
24
+ // Load instrument mappings for ticker information (needed for BigQuery)
25
+ let instrumentMappings = null;
26
+ if (process.env.BIGQUERY_ENABLED !== 'false' && calculationUtils?.loadInstrumentMappings) {
27
+ try {
28
+ instrumentMappings = await calculationUtils.loadInstrumentMappings();
29
+ if (instrumentMappings?.instrumentToTicker) {
30
+ logger.log('INFO', `[PriceFetcherHelpers] Loaded ${Object.keys(instrumentMappings.instrumentToTicker).length} instrument mappings for ticker lookup`);
31
+ }
32
+ } catch (mappingError) {
33
+ logger.log('WARN', `[PriceFetcherHelpers] Failed to load instrument mappings: ${mappingError.message}. Ticker will be set to 'unknown_{instrumentId}'`);
34
+ }
35
+ }
36
+
24
37
  // Get collection names from registry if available, fallback to hardcoded
25
38
  const { getCollectionPath } = collectionRegistry || {};
26
39
  let priceCollectionName = 'asset_prices';
@@ -50,23 +63,82 @@ exports.fetchAndStorePrices = async (config, dependencies) => {
50
63
  wasSuccessful = true;
51
64
  const results = await response.json();
52
65
  if (!Array.isArray(results)) { throw new Error('Invalid response format from API. Expected an array.'); }
53
- logger.log('INFO', `[PriceFetcherHelpers] Received ${results.length} instrument prices. Sharding...`);
54
- const shardUpdates = {};
66
+ logger.log('INFO', `[PriceFetcherHelpers] Received ${results.length} instrument prices. Processing for BigQuery...`);
67
+
68
+ // Transform daily prices to BigQuery rows
69
+ const fetchedAt = new Date().toISOString();
70
+ const bigqueryRows = [];
71
+ const shardUpdates = {}; // Keep for Firestore backward compatibility
72
+
55
73
  for (const instrumentData of results) {
56
74
  const dailyData = instrumentData?.ClosingPrices?.Daily;
57
75
  const instrumentId = instrumentData.InstrumentId;
76
+
58
77
  if (instrumentId && dailyData?.Price && dailyData?.Date) {
59
78
  const instrumentIdStr = String(instrumentId);
60
- const dateKey = dailyData.Date.substring(0, 10);
61
- const shardId = `shard_${parseInt(instrumentIdStr, 10) % SHARD_SIZE}`;
62
- if (!shardUpdates[shardId]) { shardUpdates[shardId] = {}; }
63
- const pricePath = `${instrumentIdStr}.prices.${dateKey}`;
64
- const updatePath = `${instrumentIdStr}.lastUpdated`;
65
- shardUpdates[shardId][pricePath] = dailyData.Price;
66
- shardUpdates[shardId][updatePath] = FieldValue.serverTimestamp(); } }
67
- const batchPromises = [];
68
- for (const shardId in shardUpdates) { const docRef = db.collection(priceCollectionName).doc(shardId); const payload = shardUpdates[shardId]; batchPromises.push(docRef.update(payload)); }
69
- await Promise.all(batchPromises);
79
+ const dateKey = dailyData.Date.substring(0, 10); // Extract YYYY-MM-DD
80
+
81
+ // Get ticker from mappings if available
82
+ let ticker = `unknown_${instrumentId}`;
83
+ if (instrumentMappings?.instrumentToTicker?.[instrumentIdStr]) {
84
+ ticker = instrumentMappings.instrumentToTicker[instrumentIdStr];
85
+ } else if (instrumentData.Ticker) {
86
+ ticker = instrumentData.Ticker;
87
+ }
88
+
89
+ // Prepare BigQuery row
90
+ bigqueryRows.push({
91
+ date: dateKey,
92
+ instrument_id: parseInt(instrumentId, 10),
93
+ ticker: ticker,
94
+ price: dailyData.Price,
95
+ open: null, // Daily API doesn't provide OHLC, only closing price
96
+ high: null,
97
+ low: null,
98
+ close: dailyData.Price,
99
+ volume: null,
100
+ fetched_at: fetchedAt
101
+ });
102
+
103
+ // Also prepare Firestore update for backward compatibility
104
+ if (process.env.FIRESTORE_PRICE_FETCH !== 'false') {
105
+ const shardId = `shard_${parseInt(instrumentIdStr, 10) % SHARD_SIZE}`;
106
+ if (!shardUpdates[shardId]) { shardUpdates[shardId] = {}; }
107
+ const pricePath = `${instrumentIdStr}.prices.${dateKey}`;
108
+ const updatePath = `${instrumentIdStr}.lastUpdated`;
109
+ shardUpdates[shardId][pricePath] = dailyData.Price;
110
+ shardUpdates[shardId][updatePath] = FieldValue.serverTimestamp();
111
+ }
112
+ }
113
+ }
114
+
115
+ // Write to BigQuery using load jobs (free, batched)
116
+ if (process.env.BIGQUERY_ENABLED !== 'false' && bigqueryRows.length > 0) {
117
+ try {
118
+ const { insertRows, ensureAssetPricesTable } = require('../../core/utils/bigquery_utils');
119
+ await ensureAssetPricesTable(logger);
120
+
121
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
122
+ await insertRows(datasetId, 'asset_prices', bigqueryRows, logger);
123
+
124
+ logger.log('INFO', `[PriceFetcherHelpers] Successfully stored ${bigqueryRows.length} daily price records to BigQuery`);
125
+ } catch (bqError) {
126
+ logger.log('ERROR', `[PriceFetcherHelpers] BigQuery write failed: ${bqError.message}`);
127
+ // Continue - don't fail the entire fetch for BigQuery errors
128
+ }
129
+ }
130
+
131
+ // Also write to Firestore for backward compatibility (if needed)
132
+ if (process.env.FIRESTORE_PRICE_FETCH !== 'false' && Object.keys(shardUpdates).length > 0) {
133
+ const batchPromises = [];
134
+ for (const shardId in shardUpdates) {
135
+ const docRef = db.collection(priceCollectionName).doc(shardId);
136
+ const payload = shardUpdates[shardId];
137
+ batchPromises.push(docRef.update(payload));
138
+ }
139
+ await Promise.all(batchPromises);
140
+ logger.log('INFO', `[PriceFetcherHelpers] Also stored prices to ${batchPromises.length} Firestore shards`);
141
+ }
70
142
 
71
143
  // Extract all dates from the price data and create a date tracking document
72
144
  const priceDatesSet = new Set();
@@ -133,6 +133,32 @@ exports.fetchAndStoreInsights = async (config, dependencies) => {
133
133
 
134
134
  await docRef.set(firestorePayload);
135
135
 
136
+ // Write insights to BigQuery (one row per instrument)
137
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
138
+ try {
139
+ const { insertRows, ensureInstrumentInsightsTable } = require('../../core/utils/bigquery_utils');
140
+ await ensureInstrumentInsightsTable(logger);
141
+
142
+ const fetchedAt = new Date().toISOString();
143
+ const bigqueryRows = insightsData.map(insight => {
144
+ return {
145
+ date: today,
146
+ instrument_id: parseInt(insight.instrumentId, 10),
147
+ insights_data: insight, // Store full insight object as JSON
148
+ fetched_at: fetchedAt
149
+ };
150
+ });
151
+
152
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
153
+ await insertRows(datasetId, 'instrument_insights', bigqueryRows, logger);
154
+
155
+ logger.log('INFO', `[FetchInsightsHelpers] Successfully stored ${bigqueryRows.length} insight records to BigQuery`);
156
+ } catch (bqError) {
157
+ logger.log('WARN', `[FetchInsightsHelpers] BigQuery insights write failed: ${bqError.message}`);
158
+ // Continue - Firestore write succeeded
159
+ }
160
+ }
161
+
136
162
  // Update root data indexer for today's date after insights data is stored
137
163
  try {
138
164
  const { runRootDataIndexer } = require('../../root-data-indexer/index');
@@ -315,6 +315,35 @@ async function fetchAndStorePopularInvestors(config, dependencies) {
315
315
 
316
316
  logger.log('SUCCESS', `[PopularInvestorFetch] Stored ${data.TotalRows} rankings into ${finalRankingsCollectionName}/${today}${firestorePayload._compressed ? ' (compressed)' : ''}`);
317
317
 
318
+ // Write rankings to BigQuery (one row per PI)
319
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
320
+ try {
321
+ const { insertRows, ensurePIRankingsTable } = require('../../core/utils/bigquery_utils');
322
+ await ensurePIRankingsTable(logger);
323
+
324
+ const fetchedAt = new Date().toISOString();
325
+ const bigqueryRows = data.Items.map((item, index) => {
326
+ return {
327
+ date: today,
328
+ pi_id: parseInt(item.CustomerId, 10),
329
+ username: item.UserName || null,
330
+ rank: index + 1, // Rank is position in array (1-indexed)
331
+ category: item.Category || null,
332
+ rankings_data: item, // Store full item data as JSON
333
+ fetched_at: fetchedAt
334
+ };
335
+ });
336
+
337
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
338
+ await insertRows(datasetId, 'pi_rankings', bigqueryRows, logger);
339
+
340
+ logger.log('INFO', `[PopularInvestorFetch] Successfully stored ${bigqueryRows.length} ranking records to BigQuery`);
341
+ } catch (bqError) {
342
+ logger.log('WARN', `[PopularInvestorFetch] BigQuery rankings write failed: ${bqError.message}`);
343
+ // Continue - Firestore write succeeded
344
+ }
345
+ }
346
+
318
347
  // Update the master list of Popular Investors
319
348
  // Use batched writes to avoid 500 field transform limit
320
349
  try {
@@ -426,6 +455,43 @@ async function fetchAndStorePopularInvestors(config, dependencies) {
426
455
  }
427
456
 
428
457
  logger.log('SUCCESS', `[PopularInvestorFetch] Updated master list: ${newInvestorsCount} new, ${updatedInvestorsCount} updated. Total unique PIs: ${Object.keys({ ...existingInvestors, ...investorsToUpdate }).length}`);
458
+
459
+ // Write master list updates to BigQuery
460
+ if (process.env.BIGQUERY_ENABLED !== 'false' && Object.keys(investorsToUpdate).length > 0) {
461
+ try {
462
+ const { insertRowsWithMerge, ensurePIMasterListTable } = require('../../core/utils/bigquery_utils');
463
+ await ensurePIMasterListTable(logger);
464
+
465
+ const now = new Date().toISOString();
466
+ const bigqueryRows = Object.entries(investorsToUpdate).map(([cid, investorData]) => {
467
+ // Handle Firestore Timestamp objects
468
+ const convertTimestamp = (ts) => {
469
+ if (!ts) return now;
470
+ if (ts instanceof Date) return ts.toISOString();
471
+ if (ts.toDate && typeof ts.toDate === 'function') return ts.toDate().toISOString();
472
+ if (typeof ts === 'string') return ts;
473
+ return now;
474
+ };
475
+
476
+ return {
477
+ cid: parseInt(cid, 10),
478
+ username: investorData.username,
479
+ first_seen_at: convertTimestamp(investorData.firstSeenAt),
480
+ last_seen_at: convertTimestamp(investorData.lastSeenAt),
481
+ last_updated: now
482
+ };
483
+ });
484
+
485
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
486
+ // Use MERGE to update existing records or insert new ones
487
+ await insertRowsWithMerge(datasetId, 'pi_master_list', bigqueryRows, ['cid'], logger);
488
+
489
+ logger.log('INFO', `[PopularInvestorFetch] Successfully stored ${bigqueryRows.length} master list records to BigQuery`);
490
+ } catch (bqError) {
491
+ logger.log('WARN', `[PopularInvestorFetch] BigQuery master list write failed: ${bqError.message}`);
492
+ // Continue - Firestore write succeeded
493
+ }
494
+ }
429
495
  } catch (masterListError) {
430
496
  logger.log('WARN', `[PopularInvestorFetch] Failed to update master list: ${masterListError.message}`);
431
497
  // Non-critical, continue
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * @fileoverview Main pipe: pipe.maintenance.runBackfillAssetPrices
3
3
  * A one-time function to backfill historical price data from eToro's
4
- * candle API into the new sharded `asset_prices` collection.
4
+ * candle API into BigQuery `asset_prices` table.
5
5
  */
6
6
 
7
7
  const { FieldValue } = require('@google-cloud/firestore');
8
8
  const pLimit = require('p-limit');
9
9
  const CONCURRENT_REQUESTS = 10;
10
10
  const DAYS_TO_FETCH = 365;
11
- const SHARD_SIZE = 40;
11
+ const SHARD_SIZE = 40; // Legacy, kept for Firestore compatibility if needed
12
+
13
+ // BigQuery batch size for load jobs (100MB limit, but we batch smaller for efficiency)
14
+ const BIGQUERY_BATCH_SIZE = 10000; // rows per batch
12
15
 
13
16
  /**
14
17
  * Main pipe: pipe.maintenance.runBackfillAssetPrices
@@ -42,14 +45,60 @@ exports.runBackfillAssetPrices = async (config, dependencies) => {
42
45
  headerManager.updatePerformance(selectedHeader.id, wasSuccess);
43
46
  const data = await response.json();
44
47
  const candles = data?.Candles?.[0]?.Candles;
45
- if (!Array.isArray(candles) || candles.length === 0) { logger.log('WARN', `[PriceBackfill] No candle data returned for ${ticker} (${instrumentId})`); return; }
46
- const prices = {};
47
- for (const candle of candles) { const dateKey = candle.FromDate.substring(0, 10); prices[dateKey] = candle.Close; }
48
- const shardId = `shard_${parseInt(instrumentId, 10) % SHARD_SIZE}`;
49
- const docRef = db.collection('asset_prices').doc(shardId);
50
- const payload = { [instrumentId]: { ticker: ticker, prices: prices, lastUpdated: FieldValue.serverTimestamp() } };
51
- await docRef.set(payload, { merge: true });
52
- logger.log('TRACE', `[PriceBackfill] Successfully stored data for ${ticker} (${instrumentId}) in ${shardId}`);
48
+ if (!Array.isArray(candles) || candles.length === 0) {
49
+ logger.log('WARN', `[PriceBackfill] No candle data returned for ${ticker} (${instrumentId})`);
50
+ return;
51
+ }
52
+
53
+ // Transform candles to BigQuery rows
54
+ const fetchedAt = new Date().toISOString();
55
+ const bigqueryRows = candles.map(candle => {
56
+ const dateStr = candle.FromDate.substring(0, 10); // Extract YYYY-MM-DD
57
+ return {
58
+ date: dateStr,
59
+ instrument_id: parseInt(instrumentId, 10),
60
+ ticker: ticker,
61
+ price: candle.Close || null,
62
+ open: candle.Open || null,
63
+ high: candle.High || null,
64
+ low: candle.Low || null,
65
+ close: candle.Close || null,
66
+ volume: candle.Volume || null,
67
+ fetched_at: fetchedAt
68
+ };
69
+ });
70
+
71
+ // Write to BigQuery using load jobs (free, batched)
72
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
73
+ try {
74
+ const { insertRows, ensureAssetPricesTable } = require('../../core/utils/bigquery_utils');
75
+ await ensureAssetPricesTable(logger);
76
+
77
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
78
+ await insertRows(datasetId, 'asset_prices', bigqueryRows, logger);
79
+
80
+ logger.log('TRACE', `[PriceBackfill] Successfully stored ${bigqueryRows.length} price records for ${ticker} (${instrumentId}) to BigQuery`);
81
+ } catch (bqError) {
82
+ logger.log('ERROR', `[PriceBackfill] BigQuery write failed for ${ticker} (${instrumentId}): ${bqError.message}`);
83
+ // Continue - don't fail the entire backfill for one instrument
84
+ }
85
+ }
86
+
87
+ // Also write to Firestore for backward compatibility (if needed)
88
+ // This can be removed once all readers are migrated to BigQuery
89
+ if (process.env.FIRESTORE_PRICE_BACKFILL !== 'false') {
90
+ const prices = {};
91
+ for (const candle of candles) {
92
+ const dateKey = candle.FromDate.substring(0, 10);
93
+ prices[dateKey] = candle.Close;
94
+ }
95
+ const shardId = `shard_${parseInt(instrumentId, 10) % SHARD_SIZE}`;
96
+ const docRef = db.collection('asset_prices').doc(shardId);
97
+ const payload = { [instrumentId]: { ticker: ticker, prices: prices, lastUpdated: FieldValue.serverTimestamp() } };
98
+ await docRef.set(payload, { merge: true });
99
+ logger.log('TRACE', `[PriceBackfill] Also stored data for ${ticker} (${instrumentId}) in Firestore ${shardId}`);
100
+ }
101
+
53
102
  successCount++;
54
103
  } catch (err) {
55
104
  logger.log('ERROR', `[PriceBackfill] Failed to process instrument ${instrumentId}`, { err: err.message });
@@ -245,6 +245,56 @@ exports.runRootDataIndexer = async (config, dependencies) => {
245
245
 
246
246
  // --- Define Refs & Check Paths ---
247
247
 
248
+ // =========================================================================
249
+ // BIGQUERY FIRST: Check BigQuery tables for data availability
250
+ // =========================================================================
251
+ let bigqueryHasPortfolio = false;
252
+ let bigqueryHasHistory = false;
253
+ let bigqueryHasSocial = false;
254
+ let bigqueryHasInsights = false;
255
+ let bigqueryHasPrices = false;
256
+ let bigqueryHasPIRankings = false;
257
+ let bigqueryHasPIMasterList = false;
258
+
259
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
260
+ try {
261
+ const {
262
+ queryPortfolioData,
263
+ queryHistoryData,
264
+ querySocialData,
265
+ queryInstrumentInsights,
266
+ queryAssetPrices,
267
+ queryPIRankings,
268
+ queryPIMasterList
269
+ } = require('../../core/utils/bigquery_utils');
270
+
271
+ // Check BigQuery for today's data (or target date)
272
+ const [portfolioData, historyData, socialData, insightsData, pricesData, rankingsData, masterListData] = await Promise.all([
273
+ queryPortfolioData(dateStr, null, null, logger).catch(() => null),
274
+ queryHistoryData(dateStr, null, null, logger).catch(() => null),
275
+ querySocialData(dateStr, null, null, logger).catch(() => null),
276
+ queryInstrumentInsights(dateStr, logger).catch(() => null),
277
+ queryAssetPrices(dateStr, dateStr, null, logger).catch(() => null), // Check for specific date
278
+ queryPIRankings(dateStr, logger).catch(() => null),
279
+ queryPIMasterList(logger).catch(() => null)
280
+ ]);
281
+
282
+ bigqueryHasPortfolio = portfolioData && Object.keys(portfolioData).length > 0;
283
+ bigqueryHasHistory = historyData && Object.keys(historyData).length > 0;
284
+ bigqueryHasSocial = socialData && Object.keys(socialData).length > 0;
285
+ bigqueryHasInsights = insightsData && Array.isArray(insightsData) && insightsData.length > 0;
286
+ bigqueryHasPrices = pricesData && Object.keys(pricesData).length > 0;
287
+ bigqueryHasPIRankings = rankingsData && Array.isArray(rankingsData) && rankingsData.length > 0;
288
+ bigqueryHasPIMasterList = masterListData && Object.keys(masterListData).length > 0;
289
+
290
+ if (bigqueryHasPortfolio || bigqueryHasHistory || bigqueryHasSocial || bigqueryHasInsights || bigqueryHasPrices || bigqueryHasPIRankings) {
291
+ logger.log('INFO', `[RootDataIndexer/${dateStr}] ✅ Found data in BigQuery: portfolio=${bigqueryHasPortfolio}, history=${bigqueryHasHistory}, social=${bigqueryHasSocial}, insights=${bigqueryHasInsights}, prices=${bigqueryHasPrices}, rankings=${bigqueryHasPIRankings}`);
292
+ }
293
+ } catch (bqError) {
294
+ logger.log('WARN', `[RootDataIndexer/${dateStr}] BigQuery check failed, using Firestore fallback: ${bqError.message}`);
295
+ }
296
+ }
297
+
248
298
  // 1. Standard Retail
249
299
  // Path: {normalPortfolios}/19M/snapshots/{YYYY-MM-DD}/parts/part_*
250
300
  const normPortPartsRef = db.collection(safeCollections.normalPortfolios).doc(CANARY_BLOCK_ID).collection('snapshots').doc(dateStr).collection('parts');
@@ -435,25 +485,29 @@ exports.runRootDataIndexer = async (config, dependencies) => {
435
485
  logger.log('INFO', `[RootDataIndexer/${dateStr}] Social check results - PI: ${foundPISocial}, Signed-in: ${foundSignedInSocial}`);
436
486
 
437
487
  // --- Assign to Availability ---
438
- availability.details.normalPortfolio = normPortExists;
439
- availability.details.speculatorPortfolio = specPortExists;
440
- availability.details.normalHistory = normHistExists;
441
- availability.details.speculatorHistory = specHistExists;
442
- availability.details.piRankings = piRankingsSnap.exists;
488
+ // Use BigQuery results if available, otherwise fall back to Firestore
489
+ availability.details.normalPortfolio = bigqueryHasPortfolio || normPortExists;
490
+ availability.details.speculatorPortfolio = bigqueryHasPortfolio || specPortExists;
491
+ availability.details.normalHistory = bigqueryHasHistory || normHistExists;
492
+ availability.details.speculatorHistory = bigqueryHasHistory || specHistExists;
493
+ availability.details.piRankings = bigqueryHasPIRankings || piRankingsSnap.exists;
443
494
 
444
- availability.details.piPortfolios = piPortExists;
445
- availability.details.piDeepPortfolios = piDeepExists;
446
- availability.details.piHistory = piHistExists;
495
+ availability.details.piPortfolios = bigqueryHasPortfolio || piPortExists;
496
+ availability.details.piDeepPortfolios = piDeepExists; // Legacy only, no BigQuery equivalent
497
+ availability.details.piHistory = bigqueryHasHistory || piHistExists;
447
498
 
448
499
  // PI & Signed-In Social Flags (Strict)
449
- availability.details.piSocial = foundPISocial;
450
- availability.details.hasPISocial = foundPISocial;
451
- availability.details.signedInSocial = foundSignedInSocial;
452
- availability.details.hasSignedInSocial = foundSignedInSocial;
453
-
500
+ // Use BigQuery if available, otherwise use Firestore tracking documents
501
+ const finalPISocial = bigqueryHasSocial || foundPISocial;
502
+ const finalSignedInSocial = bigqueryHasSocial || foundSignedInSocial;
503
+ availability.details.piSocial = finalPISocial;
504
+ availability.details.hasPISocial = finalPISocial;
505
+ availability.details.signedInSocial = finalSignedInSocial;
506
+ availability.details.hasSignedInSocial = finalSignedInSocial;
507
+
454
508
  // Signed-In Flags
455
- availability.details.signedInUserPortfolio = signedInPortExists;
456
- availability.details.signedInUserHistory = signedInHistExists;
509
+ availability.details.signedInUserPortfolio = bigqueryHasPortfolio || signedInPortExists;
510
+ availability.details.signedInUserHistory = bigqueryHasHistory || signedInHistExists;
457
511
  availability.details.signedInUserVerification = !verificationsQuery.empty;
458
512
 
459
513
  // New Root Data Types for Profile Metrics
@@ -461,20 +515,18 @@ exports.runRootDataIndexer = async (config, dependencies) => {
461
515
  availability.details.piPageViews = piPageViewsSnap.exists;
462
516
  availability.details.watchlistMembership = watchlistMembershipSnap.exists;
463
517
  availability.details.piAlertHistory = piAlertHistorySnap.exists;
518
+
519
+ // Aggregates (use BigQuery if available, otherwise Firestore)
520
+ availability.hasPortfolio = bigqueryHasPortfolio || normPortExists || specPortExists || piPortExists || signedInPortExists;
521
+ availability.hasHistory = bigqueryHasHistory || normHistExists || specHistExists || piHistExists || signedInHistExists;
522
+ availability.hasInsights = bigqueryHasInsights || insightsSnap.exists;
523
+ availability.hasSocial = bigqueryHasSocial || finalPISocial || finalSignedInSocial || genericSocialExists;
464
524
 
465
- // Aggregates
466
- availability.hasPortfolio = normPortExists || specPortExists || piPortExists || signedInPortExists;
467
- availability.hasHistory = normHistExists || specHistExists || piHistExists || signedInHistExists;
468
- availability.hasInsights = insightsSnap.exists;
469
- availability.hasSocial = foundPISocial || foundSignedInSocial || genericSocialExists;
470
-
471
- // [NEW] Assign Master List Availability
472
- availability.details.piMasterList = piMasterListSnap.exists;
525
+ // [NEW] Assign Master List Availability (BigQuery first, then Firestore)
526
+ availability.details.piMasterList = bigqueryHasPIMasterList || piMasterListSnap.exists;
473
527
 
474
- // Price Check
475
- // Check if the target date exists in the price availability set
476
- // The set is populated from the price tracking document's datesAvailable array
477
- const hasPriceForDate = priceAvailabilitySet.has(dateStr);
528
+ // Price Check (BigQuery first, then Firestore tracking document)
529
+ const hasPriceForDate = bigqueryHasPrices || priceAvailabilitySet.has(dateStr);
478
530
  availability.hasPrices = hasPriceForDate;
479
531
 
480
532
  if (targetDate && !hasPriceForDate) {