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.
- package/functions/api-v2/helpers/data-fetchers/firestore.js +217 -135
- package/functions/computation-system/data/CachedDataLoader.js +22 -1
- package/functions/computation-system/data/DependencyFetcher.js +118 -0
- package/functions/computation-system/persistence/ResultCommitter.js +94 -3
- package/functions/computation-system/utils/data_loader.js +244 -13
- package/functions/core/utils/bigquery_utils.js +1655 -0
- package/functions/core/utils/firestore_utils.js +99 -30
- package/functions/etoro-price-fetcher/helpers/handler_helpers.js +85 -13
- package/functions/fetch-insights/helpers/handler_helpers.js +26 -0
- package/functions/fetch-popular-investors/helpers/fetch_helpers.js +66 -0
- package/functions/price-backfill/helpers/handler_helpers.js +59 -10
- package/functions/root-data-indexer/index.js +79 -27
- package/functions/task-engine/helpers/data_storage_helpers.js +194 -102
- package/functions/task-engine/helpers/popular_investor_helpers.js +13 -7
- package/functions/task-engine/utils/bigquery_batch_manager.js +201 -0
- package/functions/task-engine/utils/firestore_batch_manager.js +21 -1
- package/index.js +34 -2
- package/package.json +3 -2
|
@@ -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
|
|
285
|
-
*
|
|
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
|
-
|
|
295
|
-
let
|
|
294
|
+
let investors = {};
|
|
295
|
+
let masterListSource = 'UNKNOWN';
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
// =========================================================================
|
|
298
|
+
// BIGQUERY FIRST: Try BigQuery for master list
|
|
299
|
+
// =========================================================================
|
|
300
|
+
if (process.env.BIGQUERY_ENABLED !== 'false') {
|
|
298
301
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
54
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
439
|
-
availability.details.
|
|
440
|
-
availability.details.
|
|
441
|
-
availability.details.
|
|
442
|
-
availability.details.
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
availability.details.
|
|
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
|
-
//
|
|
466
|
-
availability.
|
|
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
|
-
|
|
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) {
|