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