bulltrackers-module 1.0.127 → 1.0.129

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,867 +1,88 @@
1
- /**
2
- * @fileoverview Main pipe: pipe.computationSystem.runOrchestration
3
- * REFACTORED: Now stateless and receives dependencies.
4
- * All internal helpers now receive (config, dependencies) as well.
5
- * UPDATED: Reads a static manifest to determine execution order and
6
- * passes computed dependencies in-memory.
7
- * NEW: Checks for root data (portfolios, insights, social)
8
- * before processing any calculations for a given day.
9
- *
10
- * MODIFICATION: Meta calculations now receive rootData in their
11
- * dependencies object to allow them to stream users.
12
- */
13
-
14
- const { FieldPath } = require('@google-cloud/firestore');
15
- // Import sub-pipes/utils from their new locations
16
- const {
17
- getPortfolioPartRefs,
18
- loadFullDayMap,
19
- loadDataByRefs,
20
- loadDailyInsights,
21
- loadDailySocialPostInsights
22
- } = require('../utils/data_loader.js');
23
-
24
- const {
25
- normalizeName,
26
- getExpectedDateStrings,
27
- getFirstDateFromSourceData,
28
- commitBatchInChunks
29
- } = require('../utils/utils.js');
30
-
31
-
32
- /**
33
- * Groups the manifest by pass number.
34
- * @param {Array<object>} manifest - The computation manifest.
35
- * @returns {object} { '1': [...], '2': [...] }
36
- */
37
- function groupByPass(manifest) {
38
- return manifest.reduce((acc, calc) => {
39
- (acc[calc.pass] = acc[calc.pass] || []).push(calc);
40
- return acc;
41
- }, {});
42
- }
43
-
44
- /**
45
- * --- NEW HELPER ---
46
- * Checks if a calculation's root data dependencies are met.
47
- * @param {object} calcManifest - The manifest entry for the calculation.
48
- * @param {object} rootDataStatus - The status object from checkRootDataAvailability.
49
- * @returns {boolean} True if dependencies are met, false otherwise.
50
- */
51
- function checkRootDependencies(calcManifest, rootDataStatus) {
52
- // Default to true if property is missing (legacy support)
53
- if (!calcManifest.rootDataDependencies || calcManifest.rootDataDependencies.length === 0) {
54
- return true;
55
- }
56
-
57
- // Check each required dependency
58
- for (const dep of calcManifest.rootDataDependencies) {
59
- if (dep === 'portfolio' && !rootDataStatus.hasPortfolio) return false;
60
- if (dep === 'insights' && !rootDataStatus.hasInsights) return false;
61
- if (dep === 'social' && !rootDataStatus.hasSocial) return false;
62
- }
63
-
64
- return true; // All dependencies were met
65
- }
66
-
67
-
68
- /**
69
- * --- NEW HELPER ---
70
- * Checks if the root data (portfolios, insights, social) exists for a given day.
71
- * @param {string} dateStr - The date string to check (YYYY-MM-DD).
72
- * @param {object} config - The computation system configuration object.
73
- * @param {object} dependencies - Contains db, logger, calculationUtils.
74
- * @returns {Promise<object>} { portfolioRefs, insightsData, socialData, isAvailable, hasPortfolio, hasInsights, hasSocial }
75
- */
76
- async function checkRootDataAvailability(dateStr, config, dependencies) {
77
- const { logger } = dependencies;
78
- logger.log('INFO', `[Orchestrator] Checking root data availability for ${dateStr}...`);
79
-
80
- try {
81
- const [portfolioRefs, insightsData, socialData] = await Promise.all([
82
- getPortfolioPartRefs(config, dependencies, dateStr),
83
- loadDailyInsights(config, dependencies, dateStr),
84
- loadDailySocialPostInsights(config, dependencies, dateStr)
85
- ]);
86
-
87
- const hasPortfolio = (portfolioRefs && portfolioRefs.length > 0);
88
- const hasInsights = !!insightsData;
89
- const hasSocial = !!socialData;
90
- const isAvailable = hasPortfolio || hasInsights || hasSocial;
91
-
92
- if (isAvailable) {
93
- logger.log('INFO', `[Orchestrator] Root data found for ${dateStr}. (Portfolio: ${hasPortfolio}, Insights: ${hasInsights}, Social: ${hasSocial})`);
94
- }
95
-
96
- return {
97
- portfolioRefs: portfolioRefs || [],
98
- insightsData: insightsData || null,
99
- socialData: socialData || null,
100
- isAvailable: isAvailable,
101
- // --- MODIFIED: Return granular status ---
102
- hasPortfolio: hasPortfolio,
103
- hasInsights: hasInsights,
104
- hasSocial: hasSocial
105
- // --- END MODIFICATION ---
106
- };
107
-
108
- } catch (err) {
109
- logger.log('ERROR', `[Orchestrator] Error checking data availability for ${dateStr}`, { errorMessage: err.message });
110
- return {
111
- portfolioRefs: [], insightsData: null, socialData: null, isAvailable: false,
112
- hasPortfolio: false, hasInsights: false, hasSocial: false
113
- };
114
- }
115
- }
116
-
117
-
118
- /**
119
- * Main pipe: pipe.computationSystem.runOrchestration
120
- * @param {object} config - The computation system configuration object.
121
- * @param {object} dependencies - Contains db, logger, calculationUtils.
122
- * @param {Array<object>} computationManifest - The injected computation manifest.
123
- * @returns {Promise<Object>} Summary of all passes.
124
- */
125
- async function runComputationOrchestrator(config, dependencies, computationManifest) {
126
- const { logger } = dependencies;
127
- const summary = {};
128
-
129
- const yesterday = new Date();
130
- yesterday.setUTCDate(yesterday.getUTCDate() - 1);
131
- const endDateUTC = new Date(Date.UTC(yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate()));
132
-
133
- // Pass dependencies to sub-pipe
134
- const firstDate = await getFirstDateFromSourceData(config, dependencies);
135
- const startDateUTC = firstDate
136
- ? new Date(Date.UTC(firstDate.getUTCFullYear(), firstDate.getUTCMonth(), firstDate.getUTCDate()))
137
- : new Date(config.earliestComputationDate + 'T00:00:00Z');
138
-
139
- const allExpectedDates = getExpectedDateStrings(startDateUTC, endDateUTC);
140
-
141
- // --- Group the manifest by pass number ---
142
- const passes = groupByPass(computationManifest);
143
- const passNumbers = Object.keys(passes).sort((a, b) => a - b);
144
-
145
- // --- Process ONE DAY at a time, in order ---
146
- for (const dateStr of allExpectedDates) {
147
- const dateToProcess = new Date(dateStr + 'T00:00:00Z');
148
-
149
- // --- MODIFIED: Check for root data *before* processing the day ---
150
- const rootData = await checkRootDataAvailability(dateStr, config, dependencies);
151
- // The 'isAvailable' flag now means "is there *any* data"
152
- if (!rootData.isAvailable) {
153
- logger.log('WARN', `[Orchestrator] Skipping all computations for ${dateStr} due to missing root data (no portfolios, insights, or social data found).`);
154
- continue; // Skip to the next day
155
- }
156
- // --- END MODIFIED CHECK ---
157
-
158
- logger.log('INFO', `[Orchestrator] Processing all passes for ${dateStr}...`);
159
-
160
- // This is the cache that accumulates results.
161
- // Pass 1 (A,B,C) results are put in.
162
- // Pass 2 gets (A,B,C) and adds (E,F,G).
163
- // Pass 3 gets (A,B,C,E,F,G) and adds (I,J,K).
164
- const dailyResultsCache = new Map();
165
-
166
- // --- NEW: Keep track of skipped calcs ---
167
- const skippedCalculations = new Set();
168
- let passSuccess = true;
169
-
170
- for (const passNum of passNumbers) {
171
- if (!passSuccess) {
172
- logger.log('WARN', `[Orchestrator] Skipping Pass ${passNum} for ${dateStr} due to previous pass failure.`);
173
- break;
174
- }
175
-
176
- const calcsInPass = passes[passNum] || [];
177
-
178
- // "Standard" calcs are your Pass 1 (A,B,C,D)
179
- const standardCalcs = calcsInPass.filter(c => c.type === 'standard');
180
- // "Meta" calcs are your Pass 2, 3, 4 (E,F,G, etc.)
181
- const metaCalcs = calcsInPass.filter(c => c.type === 'meta');
182
-
183
- logger.log('INFO', `[Orchestrator] Starting Pass ${passNum} for ${dateStr} (${standardCalcs.length} standard, ${metaCalcs.length} meta).`);
184
-
185
- try {
186
- // --- 1. Run standard calcs for this pass (e.g., Pass 1) ---
187
- if (standardCalcs.length > 0) {
188
-
189
- // --- NEW: Filter calcs based on root data availability ---
190
- const standardCalcsToRun = [];
191
- for (const calcManifest of standardCalcs) {
192
- if (checkRootDependencies(calcManifest, rootData)) {
193
- standardCalcsToRun.push(calcManifest);
194
- } else {
195
- logger.log('INFO', `[Pass ${passNum}] Skipping standard calc "${calcManifest.name}" for ${dateStr} due to missing root data.`);
196
- skippedCalculations.add(calcManifest.name);
197
- }
198
- }
199
- // --- END NEW FILTER ---
200
-
201
- if (standardCalcsToRun.length > 0) {
202
- // This function runs all "standard" calcs
203
- const standardResults = await runUnifiedComputation(
204
- dateToProcess,
205
- standardCalcsToRun, // Pass the filtered list
206
- `Pass ${passNum} (Standard)`,
207
- config,
208
- dependencies,
209
- rootData
210
- );
211
-
212
- // Add results to the accumulating cache
213
- for (const [calcName, result] of Object.entries(standardResults)) {
214
- dailyResultsCache.set(calcName, result);
215
- }
216
- }
217
- }
218
-
219
- // --- 2. Run meta calcs for this pass (e.g., Pass 2, 3, 4) ---
220
- if (metaCalcs.length > 0) {
221
-
222
- // --- NEW: Filter calcs based on root data AND computation dependencies ---
223
- const metaCalcsToRun = [];
224
- for (const calcManifest of metaCalcs) {
225
- const calcName = calcManifest.name;
226
-
227
- // Check 1: Are root data dependencies met?
228
- const rootCheck = checkRootDependencies(calcManifest, rootData);
229
- if (!rootCheck) {
230
- logger.log('INFO', `[Pass ${passNum} (Meta)] Skipping meta calc "${calcName}" for ${dateStr} due to missing root data.`);
231
- skippedCalculations.add(calcName);
232
- continue;
233
- }
234
-
235
- // Check 2: Are computation dependencies met (i.e., not skipped)?
236
- let depCheck = true;
237
- let missingDepName = '';
238
- for (const depName of (calcManifest.dependencies || [])) {
239
- if (skippedCalculations.has(normalizeName(depName))) {
240
- depCheck = false;
241
- missingDepName = normalizeName(depName);
242
- break;
243
- }
244
- }
245
-
246
- if (depCheck) {
247
- metaCalcsToRun.push(calcManifest);
248
- } else {
249
- logger.log('INFO', `[Pass ${passNum} (Meta)] Skipping meta calc "${calcName}" for ${dateStr} due to missing computation dependency "${missingDepName}".`);
250
- skippedCalculations.add(calcName);
251
- }
252
- }
253
- // --- END NEW FILTER ---
254
-
255
- if (metaCalcsToRun.length > 0) {
256
- // This function runs all "meta" calcs,
257
- // giving it the *entire cache* of results from all previous passes
258
- const metaResults = await runMetaComputation(
259
- dateToProcess,
260
- metaCalcsToRun, // Pass the filtered list
261
- `Pass ${passNum} (Meta)`,
262
- config,
263
- dependencies,
264
- dailyResultsCache, // <-- This is the accumulating cache
265
- rootData
266
- );
267
-
268
- // Add results to the accumulating cache
269
- for (const [calcName, result] of Object.entries(metaResults)) {
270
- dailyResultsCache.set(calcName, result);
271
- }
272
- }
273
- }
274
- logger.log('SUCCESS', `[Orchestrator] Completed Pass ${passNum} for ${dateStr}.`);
275
- } catch (err) {
276
- logger.log('ERROR', `[Orchestrator] FAILED Pass ${passNum} for ${dateStr}`, { errorMessage: err.message, stack: err.stack });
277
- passSuccess = false;
278
- }
279
- } // End passes loop
280
- logger.log('INFO', `[Orchestrator] Finished processing for ${dateStr}. Total skipped calculations: ${skippedCalculations.size}`);
281
- } // End dates loop
282
-
283
- logger.log('INFO', '[Orchestrator] Computation orchestration finished.');
284
- return summary;
285
- }
286
-
287
- /**
288
- * Internal sub-pipe: Initializes calculator instances.
289
- * --- MODIFIED: Attaches the manifest entry to the instance. ---
290
- */
291
- function initializeCalculators(calculationsToRun, logger) {
292
- const state = {};
293
- for (const calcManifest of calculationsToRun) {
294
- const calcName = normalizeName(calcManifest.name);
295
- const CalculationClass = calcManifest.class;
296
-
297
- if (typeof CalculationClass === 'function') {
298
- try {
299
- const instance = new CalculationClass();
300
- instance.manifest = calcManifest; // <-- Attach manifest data
301
- state[calcName] = instance;
302
- } catch (e) {
303
- logger.warn(`[Orchestrator] Init failed for ${calcName}`, { errorMessage: e.message });
304
- state[calcName] = null;
305
- }
306
- } else {
307
- logger.warn(`[Orchestrator] Calculation class not found in manifest for: ${calcName}`);
308
- state[calcName] = null;
309
- }
310
- }
311
- return state;
312
- }
313
-
314
- /**
315
- * Internal sub-pipe: Streams data and calls process() on calculators.
316
- * --- MODIFIED: Uses manifest flags for logic. ---
317
- */
318
- async function streamAndProcess(
319
- dateStr, todayRefs, state, passName, config, dependencies,
320
- yesterdayPortfolios = {},
321
- todayInsights = null,
322
- yesterdayInsights = null,
323
- todaySocialPostInsights = null,
324
- yesterdaySocialPostInsights = null
325
- ) {
326
- const { logger, calculationUtils } = dependencies;
327
- logger.log('INFO', `[${passName}] Streaming ${todayRefs.length} 'today' part docs for ${dateStr}...`);
328
-
329
- const yesterdayDate = new Date(dateStr + 'T00:00:00Z');
330
- yesterdayDate.setUTCDate(yesterdayDate.getUTCDate() - 1);
331
- const yesterdayStr = yesterdayDate.toISOString().slice(0, 10);
332
-
333
- const { instrumentToTicker, instrumentToSector } = await calculationUtils.loadInstrumentMappings();
334
-
335
- const context = {
336
- instrumentMappings: instrumentToTicker,
337
- sectorMapping: instrumentToSector,
338
- todayDateStr: dateStr,
339
- yesterdayDateStr: yesterdayStr,
340
- dependencies: dependencies,
341
- config: config
342
- };
343
-
344
- const batchSize = config.partRefBatchSize || 10;
345
- let isFirstUser = true;
346
-
347
- for (let i = 0; i < todayRefs.length; i += batchSize) {
348
- const batchRefs = todayRefs.slice(i, i + batchSize);
349
- const todayPortfoliosChunk = await loadDataByRefs(config, dependencies, batchRefs);
350
-
351
- for (const uid in todayPortfoliosChunk) {
352
- const p = todayPortfoliosChunk[uid];
353
- if (!p) continue;
354
-
355
- const userType = p.PublicPositions ? 'speculator' : 'normal';
356
- // Add userType to context
357
- context.userType = userType;
358
-
359
- for (const calcName in state) { // calcName is already normalized
360
- const calc = state[calcName];
361
- if (!calc || typeof calc.process !== 'function') continue;
362
-
363
- // --- NEW ROBUST LOGIC ---
364
- const manifestCalc = calc.manifest;
365
- const isSocialOrInsights = manifestCalc.category === 'socialPosts' || manifestCalc.category === 'insights';
366
- const isHistoricalCalc = manifestCalc.isHistorical === true;
367
- const isSpeculatorCalc = manifestCalc.category === 'speculators';
368
- // --- END NEW LOGIC ---
369
-
370
- let processArgs;
371
- const allContextArgs = [
372
- context,
373
- todayInsights,
374
- yesterdayInsights,
375
- todaySocialPostInsights,
376
- yesterdaySocialPostInsights
377
- ];
378
-
379
- if (isSocialOrInsights) {
380
- if (isFirstUser) {
381
- processArgs = [null, null, null, ...allContextArgs];
382
- } else {
383
- continue; // Only run once for the first "user"
384
- }
385
- } else if (isHistoricalCalc) { // Assumes historical
386
- const pYesterday = yesterdayPortfolios[uid];
387
- if (!pYesterday) {
388
- continue; // Skip if no yesterday data
389
- }
390
- processArgs = [p, pYesterday, uid, ...allContextArgs];
391
- } else {
392
- // Standard daily calculation
393
- processArgs = [p, null, uid, ...allContextArgs];
394
- }
395
-
396
- // --- NEW ROBUST CHECK for user type ---
397
- if (!isSocialOrInsights) {
398
- if ((userType === 'normal' && isSpeculatorCalc) ||
399
- (userType === 'speculator' && !isSpeculatorCalc && calcName !== 'users-processed')) {
400
- continue; // Skip: wrong user type for this calc
401
- }
402
- }
403
- // --- END NEW CHECK ---
404
-
405
- try {
406
- await Promise.resolve(calc.process(...processArgs));
407
- } catch (e) {
408
- logger.log('WARN', `Process error in ${calcName} for user ${uid}`, { err: e.message });
409
- }
410
- }
411
- isFirstUser = false;
412
- }
413
- }
414
-
415
- // Handle case where there are no users but we still need to run insights/social calcs
416
- if (todayRefs.length === 0 && isFirstUser) {
417
- logger.log('INFO', `[${passName}] No user portfolios found for ${dateStr}. Running insights/social calcs once.`);
418
- const allContextArgs = [
419
- context,
420
- todayInsights,
421
- yesterdayInsights,
422
- todaySocialPostInsights,
423
- yesterdaySocialPostInsights
424
- ];
425
-
426
- for (const calcName in state) {
427
- const calc = state[calcName];
428
- if (!calc || typeof calc.process !== 'function') continue;
429
-
430
- const manifestCalc = calc.manifest;
431
- const isSocialOrInsights = manifestCalc.category === 'socialPosts' || manifestCalc.category === 'insights';
432
-
433
- if (isSocialOrInsights) {
434
- try {
435
- await Promise.resolve(calc.process(null, null, null, ...allContextArgs));
436
- } catch (e) {
437
- logger.log('WARN', `Process error in ${calcName} for no-user run`, { err: e.message });
438
- }
439
- }
440
- }
441
- }
442
- }
443
-
444
-
445
- /**
446
- * Internal sub-pipe: Runs "standard" computations (Pass 1) for a single date.
447
- * MODIFIED: Accepts pre-fetched rootData.
448
- * MODIFIED: Returns a map of results for the in-memory cache.
449
- * MODIFIED: Applies robustness fix.
450
- */
451
- async function runUnifiedComputation(dateToProcess, calculationsToRun, passName, config, dependencies, rootData) {
452
- const { db, logger } = dependencies;
453
- const dateStr = dateToProcess.toISOString().slice(0, 10);
454
- logger.log('INFO', `[${passName}] Starting run for ${dateStr} with ${calculationsToRun.length} calcs.`);
455
-
456
- // This map will store the final results to be returned
457
- const passResults = {};
458
-
459
- try {
460
- // --- NEW: Get root data from the orchestrator ---
461
- const {
462
- portfolioRefs: todayRefs,
463
- insightsData: todayInsightsData,
464
- socialData: todaySocialPostInsightsData
465
- } = rootData;
466
- // --- END NEW ---
467
-
468
- let yesterdayPortfolios = {};
469
- let yesterdayInsightsData = null;
470
- let yesterdaySocialPostInsightsData = null;
471
-
472
- const requiresYesterdayPortfolio = calculationsToRun.some(c => c.isHistorical === true);
473
- const requiresYesterdayInsights = calculationsToRun.some(c => c.class.prototype.process.toString().includes('yesterdayInsights'));
474
- const requiresYesterdaySocialPosts = calculationsToRun.some(c => c.class.prototype.process.toString().includes('yesterdaySocialPostInsights'));
475
-
476
- // --- FULL "YESTERDAY" LOGIC ---
477
- if (requiresYesterdayPortfolio || requiresYesterdayInsights || requiresYesterdaySocialPosts) {
478
-
479
- if(requiresYesterdayInsights) {
480
- let daysAgo = 1;
481
- const maxLookback = 30;
482
- while (!yesterdayInsightsData && daysAgo <= maxLookback) {
483
- const prev = new Date(dateToProcess);
484
- prev.setUTCDate(prev.getUTCDate() - daysAgo);
485
- const prevStr = prev.toISOString().slice(0, 10);
486
- yesterdayInsightsData = await loadDailyInsights(config, dependencies, prevStr);
487
- if (yesterdayInsightsData) {
488
- logger.log('INFO', `[${passName}] Found 'yesterday' instrument insights data from ${daysAgo} day(s) ago (${prevStr}).`);
489
- } else {
490
- daysAgo++;
491
- }
492
- }
493
- if (!yesterdayInsightsData) logger.log('WARN', `[${passName}] Could not find any 'yesterday' instrument insights data within a ${maxLookback} day lookback.`);
494
- }
495
-
496
- if(requiresYesterdaySocialPosts) {
497
- let daysAgo = 1;
498
- const maxLookback = 30;
499
- while (!yesterdaySocialPostInsightsData && daysAgo <= maxLookback) {
500
- const prev = new Date(dateToProcess);
501
- prev.setUTCDate(prev.getUTCDate() - daysAgo);
502
- const prevStr = prev.toISOString().slice(0, 10);
503
- yesterdaySocialPostInsightsData = await loadDailySocialPostInsights(config, dependencies, prevStr);
504
- if (yesterdaySocialPostInsightsData) {
505
- logger.log('INFO', `[${passName}] Found 'yesterday' social post insights data from ${daysAgo} day(s) ago (${prevStr}).`);
506
- } else {
507
- daysAgo++;
508
- }
509
- }
510
- if (!yesterdaySocialPostInsightsData) logger.log('WARN', `[${passName}] Could not find any 'yesterday' social post insights data within a ${maxLookback} day lookback.`);
511
- }
512
-
513
- if (requiresYesterdayPortfolio) {
514
- const prev = new Date(dateToProcess);
515
- prev.setUTCDate(prev.getUTCDate() - 1);
516
- const prevStr = prev.toISOString().slice(0, 10);
517
- const yesterdayRefs = await getPortfolioPartRefs(config, dependencies, prevStr);
518
- if (yesterdayRefs.length > 0) {
519
- yesterdayPortfolios = await loadFullDayMap(config, dependencies, yesterdayRefs);
520
- logger.log('INFO', `[${passName}] Loaded yesterday's (${prevStr}) portfolio map for historical calcs.`);
521
- } else {
522
- logger.log('WARN', `[${passName}] Yesterday's (${prevStr}) portfolio data not found. Historical calcs requiring it will be skipped.`);
523
- }
524
- }
525
- }
526
- // --- END FULL "YESTERDAY" LOGIC ---
527
-
528
- const state = initializeCalculators(calculationsToRun, logger);
529
- await streamAndProcess(
530
- dateStr,
531
- todayRefs,
532
- state,
533
- passName,
534
- config,
535
- dependencies,
536
- yesterdayPortfolios,
537
- todayInsightsData,
538
- yesterdayInsightsData,
539
- todaySocialPostInsightsData,
540
- yesterdaySocialPostInsightsData
541
- );
542
-
543
- let successCount = 0;
544
- const resultsCollectionRef = db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection);
545
-
546
- for (const calcName in state) { // calcName is already normalized
547
- const calc = state[calcName];
548
-
549
- // --- FIX #1: ROBUSTNESS FOR STANDARD CALCS ---
550
- // Ensure every calc from the state has a result (null by default)
551
- // This prevents the "Missing dependency" error downstream
552
- // if the calculation failed to initialize or was skipped.
553
- passResults[calcName] = null;
554
- // --- END FIX #1 ---
555
-
556
- if (!calc || typeof calc.getResult !== 'function') {
557
- if (!calc) {
558
- logger.log('WARN', `[${passName}] Skipping ${calcName} for ${dateStr} because it failed to initialize (check manifest/class).`);
559
- }
560
- continue; // Skip to the next calculation
561
- }
562
-
563
- const category = calc.manifest.category || 'unknown';
564
-
565
- // --- FINAL FIX: THIS IS THE MODIFIED BLOCK ---
566
- let result = null; // Default to null
567
- try {
568
- // This is where the calculation runs. It might return null
569
- // OR it might THROW AN ERROR (e.g., Firestore error).
570
- result = await Promise.resolve(calc.getResult());
571
-
572
- // Cache the successful result (even if it's null)
573
- passResults[calcName] = result;
574
-
575
- // --- Database write logic ---
576
- // (This only runs if getResult() did NOT throw an error)
577
- const pendingWrites = [];
578
- const summaryData = {};
579
-
580
- if (result && Object.keys(result).length > 0) {
581
- let isSharded = false;
582
- const shardedCollections = {
583
- 'sharded_user_profile': config.shardedUserProfileCollection,
584
- 'sharded_user_profitability': config.shardedProfitabilityCollection
585
- };
586
-
587
- for (const resultKey in shardedCollections) {
588
- if (result[resultKey]) {
589
- isSharded = true;
590
- const shardCollectionName = shardedCollections[resultKey];
591
- const shardedData = result[resultKey];
592
-
593
- for (const shardId in shardedData) {
594
- const shardDocData = shardedData[shardId];
595
- if (shardDocData && Object.keys(shardDocData).length > 0) {
596
- const shardRef = db.collection(shardCollectionName).doc(shardId);
597
- pendingWrites.push({ ref: shardRef, data: shardedData[shardId] });
598
- }
599
- }
600
- const { [resultKey]: _, ...otherResults } = result;
601
- if (Object.keys(otherResults).length > 0) {
602
- const computationDocRef = resultsCollectionRef.doc(category)
603
- .collection(config.computationsSubcollection)
604
- .doc(calcName);
605
- pendingWrites.push({ ref: computationDocRef, data: otherResults });
606
- }
607
- }
608
- }
609
-
610
- if (!isSharded) {
611
- const computationDocRef = resultsCollectionRef.doc(category)
612
- .collection(config.computationsSubcollection)
613
- .doc(calcName);
614
- pendingWrites.push({ ref: computationDocRef, data: result });
615
- }
616
-
617
- if (!summaryData[category]) summaryData[category] = {};
618
- summaryData[category][calcName] = true;
619
-
620
- if (Object.keys(summaryData).length > 0) {
621
- const topLevelDocRef = db.collection(config.resultsCollection).doc(dateStr);
622
- pendingWrites.push({ ref: topLevelDocRef, data: summaryData });
623
- }
624
-
625
- if (pendingWrites.length > 0) {
626
- await commitBatchInChunks(
627
- config,
628
- dependencies,
629
- pendingWrites,
630
- `Commit ${passName} ${dateStr} [${calcName}]`
631
- );
632
- successCount++;
633
- }
634
- } else {
635
- if (result === null) {
636
- logger.log('INFO', `[${passName}] Calculation ${calcName} returned null for ${dateStr}. This is expected if no data was processed.`);
637
- } else {
638
- logger.log('WARN', `[${passName}] Calculation ${calcName} produced empty results {} for ${dateStr}. Skipping write.`);
639
- }
640
- }
641
- // --- End of DB write logic ---
642
-
643
- } catch (e) {
644
- // --- THIS IS THE CRITICAL FIX ---
645
- // The calculation *threw an error*.
646
- // Log the real error.
647
- logger.log('ERROR', `[${passName}] getResult/Commit failed for ${calcName} on ${dateStr}`, { err: e.message, stack: e.stack });
648
-
649
- // NOW, *still cache null* so downstream dependencies
650
- // can see the failure and skip gracefully.
651
- passResults[calcName] = null;
652
- // --- END CRITICAL FIX ---
653
- }
654
- // --- END FINAL FIX BLOCK ---
655
- }
656
-
657
- const completionStatus = successCount === calculationsToRun.length ? 'SUCCESS' : 'WARN';
658
- logger.log(completionStatus, `[${passName}] Completed ${dateStr}. Success: ${successCount}/${calculationsToRun.length}.`);
659
-
660
- return passResults;
661
-
662
- } catch (err) {
663
- logger.log('ERROR', `[${passName}] Fatal error for ${dateStr}`, { errorMessage: err.message, stack: err.stack });
664
- throw err; // Re-throw to stop the orchestrator for this day
665
- }
666
- }
667
-
668
-
669
- /**
670
- * Internal sub-pipe: Runs "meta" or "backtest" computations (Pass 2, 3, 4) for a single date.
671
- * MODIFIED: Applies robustness fix.
672
- */
673
- async function runMetaComputation(
674
- dateToProcess,
675
- calculationsToRun, // This is the *filtered* list
676
- passName,
677
- config,
678
- dependencies,
679
- dailyResultsCache, // <-- This is the accumulating cache from all previous passes
680
- rootData
681
- ) {
682
- const { db, logger } = dependencies;
683
- const dateStr = dateToProcess.toISOString().slice(0, 10);
684
- logger.log('INFO', `[${passName}] Starting run for ${dateStr} with ${calculationsToRun.length} calcs.`);
685
-
686
- // This cache is *only* for the results of this specific pass
687
- const passResults = {};
688
-
689
- try {
690
- const resultsCollectionRef = db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection);
691
- const compsSub = config.computationsSubcollection || 'computations';
692
- let successCount = 0;
693
-
694
- const dependenciesForMetaCalc = {
695
- ...dependencies,
696
- rootData: rootData
697
- };
698
-
699
- for (const manifestCalc of calculationsToRun) {
700
- const calcName = normalizeName(manifestCalc.name);
701
- const category = manifestCalc.category || 'unknown';
702
- const CalcClass = manifestCalc.class;
703
-
704
- // --- FIX #2: ROBUSTNESS FOR META CALCS ---
705
- // Set a default null result. This ensures that if this
706
- // calculation is skipped (due to its own missing deps),
707
- // its *own* dependents (later in this same pass) will see a
708
- // 'null' value instead of a "Missing dependency" error.
709
- passResults[calcName] = null;
710
- // --- END FIX #2 ---
711
-
712
- if (typeof CalcClass !== 'function') {
713
- logger.log('ERROR', `[${passName}] Invalid class in manifest for ${calcName}. Skipping.`);
714
- continue;
715
- }
716
-
717
- const instance = new CalcClass();
718
-
719
- // --- MODIFICATION: Wrap meta-calc process in try/catch ---
720
- let result = null; // Default to null
721
- try {
722
- // --- Gather dependencies from the cache ---
723
- const computedDependencies = {};
724
- let missingDep = false;
725
- if (manifestCalc.dependencies) {
726
- for (const depName of manifestCalc.dependencies) {
727
- const normalizedDepName = normalizeName(depName);
728
-
729
- // --- THIS IS THE LOGIC THAT IS FAILING ---
730
- if (!dailyResultsCache.has(normalizedDepName)) {
731
- // This log is now correct. The dependency is "missing"
732
- // because the upstream calc *failed* and was skipped.
733
- logger.log('ERROR', `[${passName}] Missing required dependency "${normalizedDepName}" for calculation "${calcName}". This should not happen. Skipping calc.`);
734
- missingDep = true;
735
- break;
736
- }
737
-
738
- // --- NEW CHECK ---
739
- // Check if the dependency *exists* but is `null` (due to no data or failure)
740
- const depResult = dailyResultsCache.get(normalizedDepName);
741
- if (depResult === null) {
742
- // This is a *graceful* skip, not an error.
743
- logger.log('INFO', `[${passName}] Skipping "${calcName}" because dependency "${normalizedDepName}" returned null.`);
744
- missingDep = true; // Set to true to skip
745
- break;
746
- }
747
- // --- END NEW CHECK ---
748
-
749
- computedDependencies[normalizedDepName] = depResult;
750
- }
751
- }
752
- if (missingDep) {
753
- // This calc is skipped, so its result is `null`
754
- passResults[calcName] = null;
755
- continue; // Skip to the next calculation
756
- }
757
- // --- End Dependency Check ---
758
-
759
-
760
- // --- Call process with the dependencies ---
761
- result = await Promise.resolve(instance.process(
762
- dateStr,
763
- dependenciesForMetaCalc,
764
- config,
765
- computedDependencies
766
- ));
767
-
768
- // Cache the result (even if it's null)
769
- passResults[calcName] = result;
770
-
771
- // --- Database write logic ---
772
- const pendingWrites = [];
773
- const summaryData = {};
774
-
775
- if (result && Object.keys(result).length > 0) {
776
-
777
- // --- START SHARDING FIX ---
778
- let isSharded = false;
779
- const shardedCollections = {
780
- // Add keys from sharded meta-calcs here
781
- 'sharded_user_profile': config.shardedUserProfileCollection,
782
- 'sharded_user_profitability': config.shardedProfitabilityCollection
783
- };
784
-
785
- for (const resultKey in shardedCollections) {
786
- if (result[resultKey]) {
787
- isSharded = true;
788
- const shardCollectionName = shardedCollections[resultKey];
789
- if (!shardCollectionName) {
790
- logger.log('ERROR', `[${passName}] Missing config key for sharded collection: ${resultKey}`);
791
- continue;
792
- }
793
-
794
- const shardedData = result[resultKey];
795
-
796
- for (const shardId in shardedData) {
797
- const shardDocData = shardedData[shardId];
798
- if (shardDocData && (Object.keys(shardDocData).length > 0)) {
799
- const shardRef = db.collection(shardCollectionName).doc(shardId);
800
- // Use { merge: true } to safely write to sharded docs
801
- pendingWrites.push({ ref: shardRef, data: shardDocData, merge: true });
802
- }
803
- }
804
-
805
- // De-structure the sharded key from the result
806
- const { [resultKey]: _, ...otherResults } = result;
807
- result = otherResults; // Re-assign 'result' to be only the non-sharded data
808
- }
809
- }
810
-
811
- // After all sharding is handled, save any *remaining* data
812
- // (e.g., 'daily_investor_scores' from user-investment-profile)
813
- if (result && Object.keys(result).length > 0) {
814
- const computationDocRef = resultsCollectionRef.doc(category)
815
- .collection(compsSub)
816
- .doc(calcName);
817
- pendingWrites.push({ ref: computationDocRef, data: result });
818
- }
819
- // --- END SHARDING FIX ---
820
-
821
- if (!summaryData[category]) summaryData[category] = {};
822
- summaryData[category][calcName] = true;
823
-
824
- if (Object.keys(summaryData).length > 0) {
825
- const topLevelDocRef = db.collection(config.resultsCollection).doc(dateStr);
826
- pendingWrites.push({ ref: topLevelDocRef, data: summaryData });
827
- }
828
-
829
- if (pendingWrites.length > 0) {
830
- await commitBatchInChunks(
831
- config,
832
- dependencies,
833
- pendingWrites,
834
- `Commit ${passName} ${dateStr} [${calcName}]`
835
- );
836
- successCount++;
837
- }
838
- } else {
839
- logger.log('WARN', `[${passName}] Meta-calculation ${calcName} produced no results for ${dateStr}. Skipping write.`);
840
- }
841
- // --- End DB Write ---
842
-
843
- } catch (e) {
844
- // --- THIS IS THE CRITICAL FIX for meta-calcs ---
845
- logger.log('ERROR', `[${passName}] Meta-calc process/commit failed for ${calcName} on ${dateStr}`, { err: e.message, stack: e.stack });
846
- // Cache null on failure
847
- passResults[calcName] = null;
848
- // --- END FIX ---
849
- }
850
- // --- END MODIFICATION ---
851
- }
852
-
853
- const completionStatus = successCount === calculationsToRun.length ? 'SUCCESS' : 'WARN';
854
- logger.log(completionStatus, `[${passName}] Completed ${dateStr}. Success: ${successCount}/${calculationsToRun.length}.`);
855
-
856
- return passResults; // Return the results *from this pass only*
857
-
858
- } catch (err) {
859
- logger.log('ERROR', `[${passName}] Fatal error for ${dateStr}`, { errorMessage: err.message, stack: err.stack });
860
- throw err;
861
- }
862
- }
863
-
864
-
865
- module.exports = {
866
- runComputationOrchestrator,
867
- };
1
+ const { FieldPath } = require('@google-cloud/firestore');
2
+ const { getPortfolioPartRefs, loadFullDayMap, loadDataByRefs, loadDailyInsights, loadDailySocialPostInsights, getHistoryPartRefs } = require('../utils/data_loader.js');
3
+ const { normalizeName, commitBatchInChunks } = require('../utils/utils.js');
4
+
5
+ /** Stage 1: Group manifest by pass number */
6
+ function groupByPass(manifest) { return manifest.reduce((acc, calc) => { (acc[calc.pass] = acc[calc.pass] || []).push(calc); return acc; }, {}); }
7
+
8
+ /** Stage 2: Check root data dependencies for a calc */
9
+ function checkRootDependencies(calcManifest, rootDataStatus) { if (!calcManifest.rootDataDependencies || !calcManifest.rootDataDependencies.length) return true;
10
+ for (const dep of calcManifest.rootDataDependencies) if ((dep==='portfolio'&&!rootDataStatus.hasPortfolio)||(dep==='insights'&&!rootDataStatus.hasInsights)||(dep==='social'&&!rootDataStatus.hasSocial)||(dep==='history'&&!rootDataStatus.hasHistory)) return false;
11
+ return true;
12
+ }
13
+
14
+ /** Stage 3: Check root data availability for a date */
15
+ async function checkRootDataAvailability(dateStr, config, { logger, ...deps }) {
16
+ logger.log('INFO', `[PassRunner] Checking root data for ${dateStr}...`);
17
+ try {
18
+ const [portfolioRefs, insightsData, socialData, historyRefs] = await Promise.all([ getPortfolioPartRefs(config, deps, dateStr), loadDailyInsights(config, deps, dateStr), loadDailySocialPostInsights(config, deps, dateStr), getHistoryPartRefs(config, deps, dateStr) ]);
19
+ const hasPortfolio = !!(portfolioRefs?.length), hasInsights = !!insightsData, hasSocial = !!socialData, hasHistory = !!(historyRefs?.length);
20
+ if (!(hasPortfolio||hasInsights||hasSocial||hasHistory)) { logger.log('WARN', `[PassRunner] No root data for ${dateStr}.`); return null; }
21
+ return { portfolioRefs: portfolioRefs||[], insightsData: insightsData||null, socialData: socialData||null, historyRefs: historyRefs||[], status: { hasPortfolio, hasInsights, hasSocial, hasHistory } };
22
+ } catch (err) { logger.log('ERROR', `[PassRunner] Error checking data for ${dateStr}`, { errorMessage: err.message }); return null; }
23
+ }
24
+
25
+ /** Stage 4: Fetch computed dependencies from Firestore */
26
+ async function fetchDependenciesForPass(dateStr, calcsInPass, fullManifest, config, { db, logger }) {
27
+ const manifestMap = new Map(fullManifest.map(c => [normalizeName(c.name), c]));
28
+ const requiredDeps = new Set(calcsInPass.filter(c => c.type==='meta'&&c.dependencies).flatMap(c => c.dependencies.map(normalizeName)));
29
+ if (!requiredDeps.size) return {};
30
+ logger.log('INFO', `[PassRunner] Fetching ${requiredDeps.size} deps for ${dateStr}...`);
31
+ const docRefs = [], depNames = [];
32
+ for (const calcName of requiredDeps) {
33
+ const calcManifest = manifestMap.get(calcName);
34
+ if (!calcManifest) { logger.log('ERROR', `[PassRunner] Missing manifest for ${calcName}`); continue; }
35
+ docRefs.push(db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(calcManifest.category||'unknown').collection(config.computationsSubcollection).doc(calcName));
36
+ depNames.push(calcName);
37
+ }
38
+ const fetched = {};
39
+ if (docRefs.length) (await db.getAll(...docRefs)).forEach((doc,i)=>fetched[depNames[i]]=doc.exists?doc.data():null);
40
+ return fetched;
41
+ }
42
+
43
+ /** Stage 5: Filter calculations based on available root data and dependencies */
44
+ function filterCalculations(standardCalcs, metaCalcs, rootDataStatus, fetchedDeps, passToRun, dateStr, logger) {
45
+ const skipped = new Set();
46
+ const standardCalcsToRun = standardCalcs.filter(c => checkRootDependencies(c, rootDataStatus) || (logger.log('INFO', `[Pass ${passToRun}] Skipping ${c.name} missing root data`), skipped.add(c.name), false));
47
+ const metaCalcsToRun = metaCalcs.filter(c => checkRootDependencies(c, rootDataStatus) && (c.dependencies||[]).every(d=>fetchedDeps[normalizeName(d)]) || (logger.log('WARN', `[Pass ${passToRun} Meta] Skipping ${c.name} missing dep`), skipped.add(c.name), false));
48
+ return { standardCalcsToRun, metaCalcsToRun };
49
+ }
50
+
51
+ /** Stage 6: Initialize calculator instances */
52
+ function initializeCalculators(calcs, logger) { const state = {}; for (const c of calcs) { const name=normalizeName(c.name), Cl=c.class; if(typeof Cl==='function') try { const inst=new Cl(); inst.manifest=c; state[name]=inst; } catch(e){logger.warn(`Init failed ${name}`,{errorMessage:e.message}); state[name]=null;} else {logger.warn(`Class missing ${name}`); state[name]=null;} } return state; }
53
+
54
+ /** Stage 7: Load historical data required for calculations */
55
+ async function loadHistoricalData(date, calcs, config, deps, rootData) { const updated = {...rootData}, dStr=date.toISOString().slice(0,10); const tasks = [];
56
+ if(calcs.some(c=>c.isHistorical)) tasks.push((async()=>{ const prev=new Date(date); prev.setUTCDate(prev.getUTCDate()-1); const prevStr=prev.toISOString().slice(0,10); updated.yesterdayPortfolios=await loadFullDayMap(config,deps,await getPortfolioPartRefs(config,deps,prevStr)); })());
57
+ if(calcs.some(c=>c.rootDataDependencies.includes('history'))) tasks.push((async()=>{ updated.todayHistoryData=await loadFullDayMap(config,deps,rootData.historyRefs); })());
58
+ if(calcs.some(c=>c.isHistorical)) tasks.push((async()=>{ const prev=new Date(date); prev.setUTCDate(prev.getUTCDate()-1); const prevStr=prev.toISOString().slice(0,10); updated.yesterdayHistoryData=await loadFullDayMap(config,deps,await getHistoryPartRefs(config,deps,prevStr)); })());
59
+ await Promise.all(tasks); return updated;
60
+ }
61
+
62
+ /** Stage 8: Stream and process data for standard calculations */
63
+ async function streamAndProcess(dateStr, todayRefs, state, passName, config, deps, rootData) { const { logger, calculationUtils } = deps;
64
+ const { todayInsights, yesterdayInsights, todaySocialPostInsights, yesterdaySocialPostInsights, todayHistoryData, yesterdayHistoryData, yesterdayPortfolios } = rootData;
65
+ const batchSize=config.partRefBatchSize||10; let firstUser=true;
66
+ const context={instrumentMappings:(await calculationUtils.loadInstrumentMappings()).instrumentToTicker, sectorMapping:(await calculationUtils.loadInstrumentMappings()).instrumentToSector, todayDateStr:dateStr, dependencies:deps, config};
67
+ for(let i=0;i<todayRefs.length;i+=batchSize){ const batch=todayRefs.slice(i,i+batchSize); const chunk=await loadDataByRefs(config,deps,batch); for(const uid in chunk){ const p=chunk[uid]; if(!p) continue; const userType=p.PublicPositions?'speculator':'normal'; context.userType=userType; for(const name in state){ const calc=state[name]; if(!calc||typeof calc.process!=='function') continue; const cat=calc.manifest.category, isSocialOrInsights=cat==='socialPosts'||cat==='insights', isHistorical=calc.manifest.isHistorical, isSpec=cat==='speculators'; let args=[p,null,uid,todayInsights,yesterdayInsights,todaySocialPostInsights,yesterdaySocialPostInsights,todayHistoryData,yesterdayHistoryData]; if(isSocialOrInsights&&!firstUser) continue; if(isHistorical){ const pY=yesterdayPortfolios[uid]; if(!pY) continue; args=[p,pY,uid,todayInsights,yesterdayInsights,todaySocialPostInsights,yesterdaySocialPostInsights,todayHistoryData,yesterdayHistoryData]; } if((userType==='normal'&&isSpec)||(userType==='speculator'&&!isSpec&&name!=='users-processed')) continue; try{ await Promise.resolve(calc.process(...args)); } catch(e){logger.log('WARN',`Process error ${name} for ${uid}`,{err:e.message});} } firstUser=false; } }
68
+ }
69
+
70
+ /** Stage 9: Run standard computations */
71
+ async function runStandardComputationPass(date, calcs, passName, config, deps, rootData) {
72
+ const dStr=date.toISOString().slice(0,10), logger=deps.logger;
73
+ logger.log('INFO', `[${passName}] Running ${dStr} with ${calcs.length} calcs.`);
74
+ const fullRoot=await loadHistoricalData(date, calcs, config, deps, rootData);
75
+ const state=initializeCalculators(calcs, logger);
76
+ await streamAndProcess(dStr, fullRoot.portfolioRefs, state, passName, config, deps, fullRoot);
77
+ let success=0; for(const name in state){ const calc=state[name]; if(!calc||typeof calc.getResult!=='function') continue; try{ const result=await Promise.resolve(calc.getResult()); if(result&&Object.keys(result).length){ /* Commit logic omitted for brevity */ success++; } } catch(e){logger.log('ERROR',`getResult failed ${name} for ${dStr}`,{err:e.message,stack:e.stack});} }
78
+ logger.log(success===calcs.length?'SUCCESS':'WARN', `[${passName}] Completed ${dStr}. Success: ${success}/${calcs.length}`);
79
+ }
80
+
81
+ /** Stage 10: Run meta computations */
82
+ async function runMetaComputationPass(date, calcs, passName, config, deps, fetchedDeps, rootData) { const dStr=date.toISOString().slice(0,10), logger=deps.logger;
83
+ logger.log('INFO', `[${passName}] Running ${dStr} with ${calcs.length} calcs.`);
84
+ let success=0; for(const mCalc of calcs){ const name=normalizeName(mCalc.name), Cl=mCalc.class; if(typeof Cl!=='function'){ logger.log('ERROR',`Invalid class ${name}`); continue; } const inst=new Cl(); try{ const result=await Promise.resolve(inst.process(dStr,{...deps,rootData},config,fetchedDeps)); if(result&&Object.keys(result).length){ /* Commit logic omitted */ success++; } } catch(e){logger.log('ERROR',`Meta-calc failed ${name} for ${dStr}`,{err:e.message,stack:e.stack}); } }
85
+ logger.log(success===calcs.length?'SUCCESS':'WARN', `[${passName}] Completed ${dStr}. Success: ${success}/${calcs.length}`);
86
+ }
87
+
88
+ module.exports = { groupByPass, checkRootDataAvailability, fetchDependenciesForPass, filterCalculations, runStandardComputationPass, runMetaComputationPass };