bulltrackers-module 1.0.710 → 1.0.713

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,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) {
@@ -2,13 +2,20 @@
2
2
  * @fileoverview Data Storage Helpers for New Collection Structure
3
3
  *
4
4
  * Stores data to:
5
- * 1. Root data collections (date-based, per-user) - for computation system
5
+ * 1. BigQuery (date-based, per-user) - for computation system (NEW)
6
6
  * 2. User-centric collections (latest snapshot) - for fallback/quick access
7
7
  *
8
+ * UPDATED: Removed root data Firestore writes, now writes to BigQuery instead
8
9
  * Uses the centralized collection registry for all paths.
9
10
  */
10
11
 
11
12
  const { FieldValue } = require('@google-cloud/firestore');
13
+ const {
14
+ ensurePortfolioSnapshotsTable,
15
+ ensureTradeHistorySnapshotsTable,
16
+ ensureSocialPostSnapshotsTable,
17
+ insertRows
18
+ } = require('../../core/utils/bigquery_utils');
12
19
 
13
20
  /**
14
21
  * Store portfolio data for a signed-in user
@@ -19,23 +26,38 @@ const { FieldValue } = require('@google-cloud/firestore');
19
26
  * @param {string} params.cid - User CID
20
27
  * @param {string} params.date - Date string (YYYY-MM-DD)
21
28
  * @param {object} params.portfolioData - Portfolio data to store
29
+ * @param {object} params.bigqueryBatchManager - Optional BigQuery batch manager (if provided, batches writes)
22
30
  * @returns {Promise<void>}
23
31
  */
24
- async function storeSignedInUserPortfolio({ db, logger, collectionRegistry, cid, date, portfolioData }) {
25
- // 1. Store to root data collection (for computations)
26
- // Structure: SignedInUserPortfolioData/{date}/{cid}/{cid}
27
- const rootDataRef = db.collection('SignedInUserPortfolioData')
28
- .doc(date)
29
- .collection(String(cid))
30
- .doc(String(cid));
31
-
32
- await rootDataRef.set({
33
- ...portfolioData,
34
- fetchedAt: FieldValue.serverTimestamp(),
35
- cid: String(cid)
36
- }, { merge: false });
32
+ async function storeSignedInUserPortfolio({ db, logger, collectionRegistry, cid, date, portfolioData, bigqueryBatchManager = null }) {
33
+ // 1. Write to BigQuery (for computations) - use batch manager if provided, otherwise direct write
34
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
35
+ try {
36
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
37
+ const row = {
38
+ date: date,
39
+ user_id: Number(cid),
40
+ user_type: 'SIGNED_IN_USER',
41
+ portfolio_data: JSON.stringify(portfolioData), // BigQuery JSON type requires a string
42
+ fetched_at: new Date().toISOString()
43
+ };
44
+
45
+ if (bigqueryBatchManager) {
46
+ // Add to batch (will flush with Firestore batches)
47
+ await bigqueryBatchManager.addPortfolioRow(row);
48
+ } else {
49
+ // Direct write (fallback for when batch manager not available)
50
+ await ensurePortfolioSnapshotsTable(logger);
51
+ await insertRows(datasetId, 'portfolio_snapshots', [row], logger);
52
+ logger.log('INFO', `[DataStorage] ✅ Wrote portfolio to BigQuery for signed-in user ${cid} (date: ${date})`);
53
+ }
54
+ } catch (bqError) {
55
+ logger.log('WARN', `[DataStorage] BigQuery write failed for signed-in user ${cid} (${date}): ${bqError.message}`);
56
+ // Continue to Firestore write (fallback)
57
+ }
58
+ }
37
59
 
38
- // 2. Store latest snapshot to user-centric collection (for fallback)
60
+ // 2. Store latest snapshot to user-centric collection (for fallback/quick access)
39
61
  const { getCollectionPath } = collectionRegistry || {};
40
62
  if (!getCollectionPath) {
41
63
  throw new Error('collectionRegistry.getCollectionPath is required');
@@ -63,23 +85,38 @@ async function storeSignedInUserPortfolio({ db, logger, collectionRegistry, cid,
63
85
  * @param {string} params.cid - User CID
64
86
  * @param {string} params.date - Date string (YYYY-MM-DD)
65
87
  * @param {object} params.historyData - Trade history data to store
88
+ * @param {object} params.bigqueryBatchManager - Optional BigQuery batch manager (if provided, batches writes)
66
89
  * @returns {Promise<void>}
67
90
  */
68
- async function storeSignedInUserTradeHistory({ db, logger, collectionRegistry, cid, date, historyData }) {
69
- // 1. Store to root data collection (for computations)
70
- // Structure: SignedInUserTradeHistoryData/{date}/{cid}/{cid}
71
- const rootDataRef = db.collection('SignedInUserTradeHistoryData')
72
- .doc(date)
73
- .collection(String(cid))
74
- .doc(String(cid));
75
-
76
- await rootDataRef.set({
77
- ...historyData,
78
- fetchedAt: FieldValue.serverTimestamp(),
79
- cid: String(cid)
80
- }, { merge: false });
91
+ async function storeSignedInUserTradeHistory({ db, logger, collectionRegistry, cid, date, historyData, bigqueryBatchManager = null }) {
92
+ // 1. Write to BigQuery (for computations) - use batch manager if provided, otherwise direct write
93
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
94
+ try {
95
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
96
+ const row = {
97
+ date: date,
98
+ user_id: Number(cid),
99
+ user_type: 'SIGNED_IN_USER',
100
+ history_data: JSON.stringify(historyData), // BigQuery JSON type requires a string
101
+ fetched_at: new Date().toISOString()
102
+ };
103
+
104
+ if (bigqueryBatchManager) {
105
+ // Add to batch (will flush with Firestore batches)
106
+ await bigqueryBatchManager.addHistoryRow(row);
107
+ } else {
108
+ // Direct write (fallback for when batch manager not available)
109
+ await ensureTradeHistorySnapshotsTable(logger);
110
+ await insertRows(datasetId, 'trade_history_snapshots', [row], logger);
111
+ logger.log('INFO', `[DataStorage] ✅ Wrote trade history to BigQuery for signed-in user ${cid} (date: ${date})`);
112
+ }
113
+ } catch (bqError) {
114
+ logger.log('WARN', `[DataStorage] BigQuery write failed for signed-in user ${cid} (${date}): ${bqError.message}`);
115
+ // Continue to Firestore write (fallback)
116
+ }
117
+ }
81
118
 
82
- // 2. Store latest snapshot to user-centric collection (for fallback)
119
+ // 2. Store latest snapshot to user-centric collection (for fallback/quick access)
83
120
  const { getCollectionPath } = collectionRegistry || {};
84
121
  if (!getCollectionPath) {
85
122
  throw new Error('collectionRegistry.getCollectionPath is required');
@@ -107,30 +144,39 @@ async function storeSignedInUserTradeHistory({ db, logger, collectionRegistry, c
107
144
  * @param {string} params.cid - User CID
108
145
  * @param {string} params.date - Date string (YYYY-MM-DD)
109
146
  * @param {Array} params.posts - Array of social posts
147
+ * @param {object} params.bigqueryBatchManager - Optional BigQuery batch manager (if provided, batches writes)
110
148
  * @returns {Promise<void>}
111
149
  */
112
- async function storeSignedInUserSocialPosts({ db, logger, collectionRegistry, cid, date, posts }) {
113
- // 1. Store to root data collection (for computations)
114
- // Structure: SignedInUserSocialPostData/{date}/{cid}/{cid}
115
- const rootDataRef = db.collection('SignedInUserSocialPostData')
116
- .doc(date)
117
- .collection(String(cid))
118
- .doc(String(cid));
119
-
120
- const postsMap = {};
121
- for (const post of posts) {
122
- if (post.id || post.postId) {
123
- postsMap[post.id || post.postId] = post;
150
+ async function storeSignedInUserSocialPosts({ db, logger, collectionRegistry, cid, date, posts, bigqueryBatchManager = null }) {
151
+ // 1. Write to BigQuery (for computations)
152
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
153
+ try {
154
+ await ensureSocialPostSnapshotsTable(logger);
155
+
156
+ const postsMap = {};
157
+ for (const post of posts) {
158
+ if (post.id || post.postId) {
159
+ postsMap[post.id || post.postId] = post;
160
+ }
161
+ }
162
+
163
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
164
+ const row = {
165
+ date: date,
166
+ user_id: Number(cid),
167
+ user_type: 'SIGNED_IN_USER',
168
+ posts_data: JSON.stringify({ posts: postsMap, postCount: posts.length }), // BigQuery JSON type requires a string
169
+ fetched_at: new Date().toISOString()
170
+ };
171
+
172
+ await insertRows(datasetId, 'social_post_snapshots', [row], logger);
173
+ logger.log('INFO', `[DataStorage] ✅ Wrote social posts to BigQuery for signed-in user ${cid} (date: ${date}, ${posts.length} posts)`);
174
+ } catch (bqError) {
175
+ logger.log('WARN', `[DataStorage] BigQuery write failed for signed-in user ${cid} (${date}): ${bqError.message}`);
176
+ // Continue to Firestore write (fallback)
124
177
  }
125
178
  }
126
179
 
127
- await rootDataRef.set({
128
- posts: postsMap,
129
- fetchedAt: FieldValue.serverTimestamp(),
130
- cid: String(cid),
131
- postCount: posts.length
132
- }, { merge: false });
133
-
134
180
  // 2. Store latest posts to user-centric collection (for fallback)
135
181
  // Path structure: SignedInUsers/{cid}/posts/{postId}
136
182
  // Construct path directly - we know the structure
@@ -180,31 +226,48 @@ async function storeSignedInUserSocialPosts({ db, logger, collectionRegistry, ci
180
226
  * @param {string} params.date - Date string (YYYY-MM-DD)
181
227
  * @param {object} params.portfolioData - Portfolio data to store
182
228
  * @param {object} params.deepPortfolioData - Optional deep portfolio data
229
+ * @param {object} params.bigqueryBatchManager - Optional BigQuery batch manager (if provided, batches writes)
183
230
  * @returns {Promise<void>}
184
231
  */
185
- async function storePopularInvestorPortfolio({ db, logger, collectionRegistry, cid, date, portfolioData, deepPortfolioData = null }) {
186
- // 1. Store overall portfolio to root data collection
187
- // Structure: PopularInvestorPortfolioData/{date}/{cid}/{cid}
188
- const rootDataRef = db.collection('PopularInvestorPortfolioData')
189
- .doc(date)
190
- .collection(String(cid))
191
- .doc(String(cid));
192
-
193
- const portfolioDoc = {
194
- ...portfolioData,
195
- fetchedAt: FieldValue.serverTimestamp(),
196
- cid: String(cid)
197
- };
198
-
199
- // 2. If deep portfolio data exists, merge it
200
- if (deepPortfolioData && deepPortfolioData.positions) {
201
- portfolioDoc.deepPositions = deepPortfolioData.positions;
202
- portfolioDoc.deepFetchedAt = FieldValue.serverTimestamp();
232
+ async function storePopularInvestorPortfolio({ db, logger, collectionRegistry, cid, date, portfolioData, deepPortfolioData = null, bigqueryBatchManager = null }) {
233
+ // 1. Write to BigQuery (for computations) - use batch manager if provided, otherwise direct write
234
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
235
+ try {
236
+ const portfolioDoc = {
237
+ ...portfolioData,
238
+ cid: String(cid)
239
+ };
240
+
241
+ // If deep portfolio data exists, merge it
242
+ if (deepPortfolioData && deepPortfolioData.positions) {
243
+ portfolioDoc.deepPositions = deepPortfolioData.positions;
244
+ }
245
+
246
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
247
+ const row = {
248
+ date: date,
249
+ user_id: Number(cid),
250
+ user_type: 'POPULAR_INVESTOR',
251
+ portfolio_data: JSON.stringify(portfolioDoc), // BigQuery JSON type requires a string
252
+ fetched_at: new Date().toISOString()
253
+ };
254
+
255
+ if (bigqueryBatchManager) {
256
+ // Add to batch (will flush with Firestore batches)
257
+ await bigqueryBatchManager.addPortfolioRow(row);
258
+ } else {
259
+ // Direct write (fallback for when batch manager not available)
260
+ await ensurePortfolioSnapshotsTable(logger);
261
+ await insertRows(datasetId, 'portfolio_snapshots', [row], logger);
262
+ logger.log('INFO', `[DataStorage] ✅ Wrote portfolio to BigQuery for PI ${cid} (date: ${date})`);
263
+ }
264
+ } catch (bqError) {
265
+ logger.log('WARN', `[DataStorage] BigQuery write failed for PI ${cid} (${date}): ${bqError.message}`);
266
+ // Continue to Firestore write (fallback)
267
+ }
203
268
  }
204
269
 
205
- await rootDataRef.set(portfolioDoc, { merge: false });
206
-
207
- // 3. Store latest snapshot to user-centric collection (for fallback)
270
+ // 2. Store latest snapshot to user-centric collection (for fallback/quick access)
208
271
  const { getCollectionPath } = collectionRegistry || {};
209
272
  if (!getCollectionPath) {
210
273
  throw new Error('collectionRegistry.getCollectionPath is required');
@@ -231,23 +294,38 @@ async function storePopularInvestorPortfolio({ db, logger, collectionRegistry, c
231
294
  * @param {string} params.cid - PI CID
232
295
  * @param {string} params.date - Date string (YYYY-MM-DD)
233
296
  * @param {object} params.historyData - Trade history data to store
297
+ * @param {object} params.bigqueryBatchManager - Optional BigQuery batch manager (if provided, batches writes)
234
298
  * @returns {Promise<void>}
235
299
  */
236
- async function storePopularInvestorTradeHistory({ db, logger, collectionRegistry, cid, date, historyData }) {
237
- // 1. Store to root data collection (for computations)
238
- // Structure: PopularInvestorTradeHistoryData/{date}/{cid}/{cid}
239
- const rootDataRef = db.collection('PopularInvestorTradeHistoryData')
240
- .doc(date)
241
- .collection(String(cid))
242
- .doc(String(cid));
243
-
244
- await rootDataRef.set({
245
- ...historyData,
246
- fetchedAt: FieldValue.serverTimestamp(),
247
- cid: String(cid)
248
- }, { merge: false });
300
+ async function storePopularInvestorTradeHistory({ db, logger, collectionRegistry, cid, date, historyData, bigqueryBatchManager = null }) {
301
+ // 1. Write to BigQuery (for computations) - use batch manager if provided, otherwise direct write
302
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
303
+ try {
304
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
305
+ const row = {
306
+ date: date,
307
+ user_id: Number(cid),
308
+ user_type: 'POPULAR_INVESTOR',
309
+ history_data: JSON.stringify(historyData), // BigQuery JSON type requires a string
310
+ fetched_at: new Date().toISOString()
311
+ };
312
+
313
+ if (bigqueryBatchManager) {
314
+ // Add to batch (will flush with Firestore batches)
315
+ await bigqueryBatchManager.addHistoryRow(row);
316
+ } else {
317
+ // Direct write (fallback for when batch manager not available)
318
+ await ensureTradeHistorySnapshotsTable(logger);
319
+ await insertRows(datasetId, 'trade_history_snapshots', [row], logger);
320
+ logger.log('INFO', `[DataStorage] ✅ Wrote trade history to BigQuery for PI ${cid} (date: ${date})`);
321
+ }
322
+ } catch (bqError) {
323
+ logger.log('WARN', `[DataStorage] BigQuery write failed for PI ${cid} (${date}): ${bqError.message}`);
324
+ // Continue to Firestore write (fallback)
325
+ }
326
+ }
249
327
 
250
- // 2. Store latest snapshot to user-centric collection (for fallback)
328
+ // 2. Store latest snapshot to user-centric collection (for fallback/quick access)
251
329
  const { getCollectionPath } = collectionRegistry || {};
252
330
  if (!getCollectionPath) {
253
331
  throw new Error('collectionRegistry.getCollectionPath is required');
@@ -275,30 +353,44 @@ async function storePopularInvestorTradeHistory({ db, logger, collectionRegistry
275
353
  * @param {string} params.cid - PI CID
276
354
  * @param {string} params.date - Date string (YYYY-MM-DD)
277
355
  * @param {Array} params.posts - Array of social posts
356
+ * @param {object} params.bigqueryBatchManager - Optional BigQuery batch manager (if provided, batches writes)
278
357
  * @returns {Promise<void>}
279
358
  */
280
- async function storePopularInvestorSocialPosts({ db, logger, collectionRegistry, cid, date, posts }) {
281
- // 1. Store to root data collection (for computations)
282
- // Structure: PopularInvestorSocialPostData/{date}/{cid}/{cid}
283
- const rootDataRef = db.collection('PopularInvestorSocialPostData')
284
- .doc(date)
285
- .collection(String(cid))
286
- .doc(String(cid));
287
-
288
- const postsMap = {};
289
- for (const post of posts) {
290
- if (post.id || post.postId) {
291
- postsMap[post.id || post.postId] = post;
359
+ async function storePopularInvestorSocialPosts({ db, logger, collectionRegistry, cid, date, posts, bigqueryBatchManager = null }) {
360
+ // 1. Write to BigQuery (for computations) - use batch manager if provided, otherwise direct write
361
+ if (process.env.BIGQUERY_ENABLED !== 'false') {
362
+ try {
363
+ const postsMap = {};
364
+ for (const post of posts) {
365
+ if (post.id || post.postId) {
366
+ postsMap[post.id || post.postId] = post;
367
+ }
368
+ }
369
+
370
+ const datasetId = process.env.BIGQUERY_DATASET_ID || 'bulltrackers_data';
371
+ const row = {
372
+ date: date,
373
+ user_id: Number(cid),
374
+ user_type: 'POPULAR_INVESTOR',
375
+ posts_data: JSON.stringify({ posts: postsMap, postCount: posts.length }), // BigQuery JSON type requires a string
376
+ fetched_at: new Date().toISOString()
377
+ };
378
+
379
+ if (bigqueryBatchManager) {
380
+ // Add to batch (will flush with Firestore batches)
381
+ await bigqueryBatchManager.addSocialRow(row);
382
+ } else {
383
+ // Direct write (fallback for when batch manager not available)
384
+ await ensureSocialPostSnapshotsTable(logger);
385
+ await insertRows(datasetId, 'social_post_snapshots', [row], logger);
386
+ logger.log('INFO', `[DataStorage] ✅ Wrote social posts to BigQuery for PI ${cid} (date: ${date}, ${posts.length} posts)`);
387
+ }
388
+ } catch (bqError) {
389
+ logger.log('WARN', `[DataStorage] BigQuery write failed for PI ${cid} (${date}): ${bqError.message}`);
390
+ // Continue to Firestore write (fallback)
292
391
  }
293
392
  }
294
393
 
295
- await rootDataRef.set({
296
- posts: postsMap,
297
- fetchedAt: FieldValue.serverTimestamp(),
298
- cid: String(cid),
299
- postCount: posts.length
300
- }, { merge: false });
301
-
302
394
  // 2. Store latest posts to user-centric collection (for fallback)
303
395
  // Path structure: PopularInvestors/{piCid}/posts/{postId}
304
396
  // Construct path directly - we know the structure