bulltrackers-module 1.0.215 → 1.0.216

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,8 +1,13 @@
1
1
  /**
2
- * FILENAME: bulltrackers-module/functions/computation-system/helpers/orchestration_helpers.js
3
- * FIXED: 'commitResults' now records the CODE HASH in the status document
4
- * instead of a boolean, enabling auto-invalidation on code changes.
5
- * UPDATED: Dynamic math context loading from 'layers/index.js' to support modular layers.
2
+ * FILENAME: computation-system/helpers/orchestration_helpers.js
3
+ * FEATURE: Dynamic Auto-Sharding (Transparent 1MB Limit Handling)
4
+ * * DESCRIPTION:
5
+ * This module orchestrates the execution of computations. It handles:
6
+ * 1. Data Availability Checks
7
+ * 2. Dependency Injection (fetching results from previous passes)
8
+ * 3. Transparent Auto-Sharding:
9
+ * - Writes: Automatically detects if a result > 900KB. Splits it into a '_shards' subcollection.
10
+ * - Reads: Automatically detects sharded pointers and re-assembles the data.
6
11
  */
7
12
 
8
13
  const { ComputationController } = require('../controllers/computation_controller');
@@ -13,60 +18,27 @@ const {
13
18
  getHistoryPartRefs, streamPortfolioData, streamHistoryData,
14
19
  getRelevantShardRefs, loadDataByRefs
15
20
  } = require('../utils/data_loader');
16
-
17
- // --- DYNAMIC LAYER LOADING ---
18
- // Replaces the old static import from 'math_primitives.js'
19
21
  const mathLayer = require('../layers/index.js');
20
-
21
22
  const pLimit = require('p-limit');
22
23
 
23
- // Mappings to ensure backward compatibility with existing calculation code
24
- // (e.g. allowing 'math.compute' to resolve to 'MathPrimitives')
24
+ // Mappings for backward compatibility
25
25
  const LEGACY_MAPPING = {
26
- DataExtractor: 'extract',
27
- HistoryExtractor: 'history',
28
- MathPrimitives: 'compute',
29
- Aggregators: 'aggregate',
30
- Validators: 'validate',
31
- SignalPrimitives: 'signals',
32
- SCHEMAS: 'schemas',
33
- DistributionAnalytics: 'distribution',
34
- TimeSeries: 'TimeSeries',
35
- priceExtractor: 'priceExtractor',
36
- InsightsExtractor: 'insights',
37
- UserClassifier: 'classifier',
38
- CognitiveBiases: 'bias',
39
- SkillAttribution: 'skill',
40
- Psychometrics: 'psychometrics'
26
+ DataExtractor: 'extract', HistoryExtractor: 'history', MathPrimitives: 'compute', Aggregators: 'aggregate', Validators: 'validate', SignalPrimitives: 'signals', SCHEMAS: 'schemas', DistributionAnalytics: 'distribution', TimeSeries: 'TimeSeries', priceExtractor: 'priceExtractor', InsightsExtractor: 'insights', UserClassifier: 'classifier', CognitiveBiases: 'bias', SkillAttribution: 'skill', Psychometrics: 'psychometrics'
41
27
  };
42
28
 
43
29
  function groupByPass(manifest) { return manifest.reduce((acc, calc) => { (acc[calc.pass] = acc[calc.pass] || []).push(calc); return acc; }, {}); }
44
30
 
45
- /**
46
- * --- PASSIVE DATA VALIDATION ---
47
- */
48
31
  function validateResultPatterns(logger, calcName, results, category) {
49
32
  if (category === 'speculator' || category === 'speculators') return;
50
- const tickers = Object.keys(results);
51
- const totalItems = tickers.length;
52
- if (totalItems < 5) return;
53
- const sampleTicker = tickers.find(t => results[t] && typeof results[t] === 'object');
54
- if (!sampleTicker) return;
55
- const keys = Object.keys(results[sampleTicker]);
56
- keys.forEach(key => {
33
+ const tickers = Object.keys(results); const totalItems = tickers.length; if (totalItems < 5) return;
34
+ const sampleTicker = tickers.find(t => results[t] && typeof results[t] === 'object'); if (!sampleTicker) return;
35
+ Object.keys(results[sampleTicker]).forEach(key => {
57
36
  if (key.startsWith('_')) return;
58
- let nullCount = 0;
59
- let nanCount = 0;
60
- let undefinedCount = 0;
61
- for (const t of tickers) {
62
- const val = results[t][key];
63
- if (val === null) nullCount++;
64
- if (val === undefined) undefinedCount++;
65
- if (typeof val === 'number' && isNaN(val)) nanCount++;
66
- }
67
- if (nanCount === totalItems) { logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is NaN for 100% of ${totalItems} items.`);
68
- } else if (undefinedCount === totalItems) { logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is UNDEFINED for 100% of ${totalItems} items.`); }
69
- else if (nullCount > (totalItems * 0.9)) { logger.log('WARN', `[DataQuality] Calc '${calcName}' field '${key}' is NULL for ${nullCount}/${totalItems} items.`); }
37
+ let nullCount = 0, nanCount = 0, undefinedCount = 0;
38
+ for (const t of tickers) { const val = results[t][key]; if (val === null) nullCount++; if (val === undefined) undefinedCount++; if (typeof val === 'number' && isNaN(val)) nanCount++; }
39
+ if (nanCount === totalItems) logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is NaN for 100% of items.`);
40
+ else if (undefinedCount === totalItems) logger.log('ERROR', `[DataQuality] Calc '${calcName}' field '${key}' is UNDEFINED for 100% of items.`);
41
+ else if (nullCount > (totalItems * 0.9)) logger.log('WARN', `[DataQuality] Calc '${calcName}' field '${key}' is NULL for ${nullCount}/${totalItems} items.`);
70
42
  });
71
43
  }
72
44
 
@@ -74,11 +46,11 @@ function checkRootDependencies(calcManifest, rootDataStatus) {
74
46
  const missing = [];
75
47
  if (!calcManifest.rootDataDependencies) return { canRun: true, missing };
76
48
  for (const dep of calcManifest.rootDataDependencies) {
77
- if (dep === 'portfolio' && !rootDataStatus.hasPortfolio) missing.push('portfolio');
78
- else if (dep === 'insights' && !rootDataStatus.hasInsights) missing.push('insights');
79
- else if (dep === 'social' && !rootDataStatus.hasSocial) missing.push('social');
80
- else if (dep === 'history' && !rootDataStatus.hasHistory) missing.push('history');
81
- else if (dep === 'price' && !rootDataStatus.hasPrices) missing.push('price');
49
+ if (dep === 'portfolio' && !rootDataStatus.hasPortfolio) missing.push('portfolio');
50
+ else if (dep === 'insights' && !rootDataStatus.hasInsights) missing.push('insights');
51
+ else if (dep === 'social' && !rootDataStatus.hasSocial) missing.push('social');
52
+ else if (dep === 'history' && !rootDataStatus.hasHistory) missing.push('history');
53
+ else if (dep === 'price' && !rootDataStatus.hasPrices) missing.push('price');
82
54
  }
83
55
  return { canRun: missing.length === 0, missing };
84
56
  }
@@ -88,115 +60,111 @@ async function checkRootDataAvailability(dateStr, config, dependencies, earliest
88
60
  const dateToProcess = new Date(dateStr + 'T00:00:00Z');
89
61
  let portfolioRefs = [], historyRefs = [];
90
62
  let hasPortfolio = false, hasInsights = false, hasSocial = false, hasHistory = false, hasPrices = false, insightsData = null, socialData = null;
91
-
92
63
  try {
93
64
  const tasks = [];
94
- if (dateToProcess >= earliestDates.portfolio) tasks.push(getPortfolioPartRefs (config, dependencies, dateStr).then(r => { portfolioRefs = r; hasPortfolio = !!r.length; }));
95
- if (dateToProcess >= earliestDates.insights) tasks.push(loadDailyInsights (config, dependencies, dateStr).then(r => { insightsData = r; hasInsights = !!r; }));
96
- if (dateToProcess >= earliestDates.social) tasks.push(loadDailySocialPostInsights (config, dependencies, dateStr).then(r => { socialData = r; hasSocial = !!r; }));
97
- if (dateToProcess >= earliestDates.history) tasks.push(getHistoryPartRefs (config, dependencies, dateStr).then(r => { historyRefs = r; hasHistory = !!r.length; }));
98
-
65
+ if (dateToProcess >= earliestDates.portfolio) tasks.push(getPortfolioPartRefs(config, dependencies, dateStr).then(r => { portfolioRefs = r; hasPortfolio = !!r.length; }));
66
+ if (dateToProcess >= earliestDates.insights) tasks.push(loadDailyInsights(config, dependencies, dateStr).then(r => { insightsData = r; hasInsights = !!r; }));
67
+ if (dateToProcess >= earliestDates.social) tasks.push(loadDailySocialPostInsights(config, dependencies, dateStr).then(r => { socialData = r; hasSocial = !!r; }));
68
+ if (dateToProcess >= earliestDates.history) tasks.push(getHistoryPartRefs(config, dependencies, dateStr).then(r => { historyRefs = r; hasHistory = !!r.length; }));
99
69
  if (dateToProcess >= earliestDates.price) { tasks.push(checkPriceDataAvailability(config, dependencies).then(r => { hasPrices = r; })); }
100
70
  await Promise.all(tasks);
101
71
  if (!(hasPortfolio || hasInsights || hasSocial || hasHistory || hasPrices)) return null;
102
-
103
- return {
104
- portfolioRefs,
105
- historyRefs,
106
- todayInsights: insightsData,
107
- todaySocialPostInsights: socialData,
108
- status: { hasPortfolio, hasInsights, hasSocial, hasHistory, hasPrices },
109
- yesterdayPortfolioRefs: null
110
- };
111
-
72
+ return { portfolioRefs, historyRefs, todayInsights: insightsData, todaySocialPostInsights: socialData, status: { hasPortfolio, hasInsights, hasSocial, hasHistory, hasPrices }, yesterdayPortfolioRefs: null };
112
73
  } catch (err) { logger.log('ERROR', `Error checking data: ${err.message}`); return null; }
113
74
  }
114
75
 
115
76
  async function firestoreHelper(action, { key, updates, config, db }) {
116
- const collections = { price: config.priceCollection || 'asset_prices', status: config.computationStatusCollection || 'computation_status', };
117
-
118
- switch (action) {
119
- case 'checkAvailability': {
120
- try { const snapshot = await db.collection(collections.price).limit(1).get(); return !snapshot.empty; } catch (e) { return false; } }
121
-
122
- case 'fetchStatus': {
123
- if (!key) throw new Error('fetchStatus requires a key');
124
- const docRef = db.collection(collections.status).doc(key);
125
- const snap = await docRef.get();
126
- return snap.exists ? snap.data() : {};
77
+ const collections = { price: config.priceCollection || 'asset_prices', status: config.computationStatusCollection || 'computation_status', };
78
+ switch (action) {
79
+ case 'checkAvailability': try { const snapshot = await db.collection(collections.price).limit(1).get(); return !snapshot.empty; } catch (e) { return false; }
80
+ case 'fetchStatus': { if (!key) throw new Error('fetchStatus requires a key'); const docRef = db.collection(collections.status).doc(key); const snap = await docRef.get(); return snap.exists ? snap.data() : {}; }
81
+ case 'updateStatus': { if (!key) throw new Error('updateStatus requires a key'); if (!updates || Object.keys(updates).length === 0) return; const docRef = db.collection(collections.status).doc(key); await docRef.set(updates, { merge: true }); return true; }
82
+ default: throw new Error(`Unknown action: ${action}`);
127
83
  }
128
-
129
- case 'updateStatus': {
130
- if (!key) throw new Error('updateStatus requires a key');
131
- if (!updates || Object.keys(updates).length === 0) return;
132
- const docRef = db.collection(collections.status).doc(key);
133
- await docRef.set(updates, { merge: true });
134
- return true;
135
- }
136
-
137
- default: throw new Error(`Unknown action: ${action}`); }
138
84
  }
139
85
 
140
- async function checkPriceDataAvailability (config, dependencies) { return firestoreHelper('checkAvailability', { config, db: dependencies.db }); }
141
- async function fetchComputationStatus (dateStr, config, { db }) { return firestoreHelper('fetchStatus', { key: dateStr, config, db }); }
142
- async function fetchGlobalComputationStatus (config, { db }) { return firestoreHelper('fetchStatus', { key: 'global_status', config, db }); }
143
- async function updateComputationStatus (dateStr, updates, config, { db }) { return firestoreHelper('updateStatus', { key: dateStr, updates, config, db }); }
144
-
86
+ async function checkPriceDataAvailability(config, dependencies) { return firestoreHelper('checkAvailability', { config, db: dependencies.db }); }
87
+ async function fetchComputationStatus(dateStr, config, { db }) { return firestoreHelper('fetchStatus', { key: dateStr, config, db }); }
88
+ async function fetchGlobalComputationStatus(config, { db }) { return firestoreHelper('fetchStatus', { key: 'global_status', config, db }); }
89
+ async function updateComputationStatus(dateStr, updates, config, { db }) { return firestoreHelper('updateStatus', { key: dateStr, updates, config, db }); }
145
90
 
91
+ /**
92
+ * --- REFACTORED: fetchExistingResults ---
93
+ * Transparently handles both standard documents and auto-sharded documents.
94
+ * 1. Fetches the doc.
95
+ * 2. Checks for `_sharded: true` flag.
96
+ * 3. If sharded, fetches subcollection and merges data back into a single object.
97
+ */
146
98
  async function fetchExistingResults(dateStr, calcsInPass, fullManifest, config, { db }, includeSelf = false) {
147
- const manifestMap = new Map(fullManifest.map(c => [normalizeName(c.name), c]));
99
+ const manifestMap = new Map(fullManifest.map(c => [normalizeName(c.name), c]));
148
100
  const calcsToFetch = new Set();
149
- for (const calc of calcsInPass) { if (calc.dependencies) { calc.dependencies.forEach(d => calcsToFetch.add(normalizeName(d))); } if (includeSelf && calc.isHistorical) { calcsToFetch.add(normalizeName(calc.name)); } }
101
+ for (const calc of calcsInPass) { if (calc.dependencies) calc.dependencies.forEach(d => calcsToFetch.add(normalizeName(d))); if (includeSelf && calc.isHistorical) calcsToFetch.add(normalizeName(calc.name)); }
150
102
  if (!calcsToFetch.size) return {};
151
103
  const fetched = {};
152
104
  const docRefs = [];
153
105
  const names = [];
106
+
107
+ // 1. Prepare Reads
154
108
  for (const name of calcsToFetch) {
155
109
  const m = manifestMap.get(name);
156
- if (m) { docRefs.push(db.collection(config.resultsCollection).doc(dateStr) .collection(config.resultsSubcollection).doc(m.category || 'unknown') .collection(config.computationsSubcollection).doc(name)); names.push(name); } }
157
- if (docRefs.length) { const snaps = await db.getAll(...docRefs); snaps.forEach((doc, i) => { if (doc.exists && doc.data()._completed) { fetched[names[i]] = doc.data(); } }); }
110
+ if (m) { docRefs.push(db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(m.category || 'unknown').collection(config.computationsSubcollection).doc(name)); names.push(name); }
111
+ }
112
+
113
+ if (docRefs.length) {
114
+ const snaps = await db.getAll(...docRefs);
115
+ const hydrationPromises = [];
116
+
117
+ // 2. Process Initial Snapshots
118
+ snaps.forEach((doc, i) => { const name = names[i]; if (!doc.exists) return; const data = doc.data(); if (data._sharded === true) { hydrationPromises.push(hydrateAutoShardedResult(doc.ref, name)); } else if (data._completed) { fetched[name] = data; } }); // CHECK FOR AUTO-SHARDING FLAG
119
+
120
+
121
+ // 3. Hydrate Sharded Data in Parallel
122
+ if (hydrationPromises.length > 0) { const hydratedResults = await Promise.all(hydrationPromises); hydratedResults.forEach(res => { fetched[res.name] = res.data; }); }
123
+ }
158
124
  return fetched;
159
125
  }
160
126
 
127
+ /**
128
+ * Helper: Fetches all docs in the '_shards' subcollection and merges them.
129
+ */
130
+ async function hydrateAutoShardedResult(docRef, resultName) {
131
+ // Determine subcollection name (defaulting to '_shards')
132
+ const shardsCol = docRef.collection('_shards');
133
+ const snapshot = await shardsCol.get();
134
+
135
+ const assembledData = { _completed: true }; // Rebuild the object
136
+
137
+ snapshot.forEach(doc => { const chunk = doc.data(); Object.assign(assembledData, chunk); });
138
+
139
+ // Remove internal flags if they leaked into the shards
140
+ delete assembledData._sharded;
141
+ delete assembledData._completed;
142
+
143
+ return { name: resultName, data: assembledData };
144
+ }
145
+
161
146
  async function streamAndProcess(dateStr, state, passName, config, deps, rootData, portfolioRefs, historyRefs, fetchedDeps, previousFetchedDeps) {
162
147
  const { logger } = deps;
163
- const controller = new ComputationController(config, deps);
164
- const calcs = Object.values(state).filter(c => c && c.manifest);
165
- const streamingCalcs = calcs.filter(c => c.manifest.rootDataDependencies.includes('portfolio') || c.manifest.rootDataDependencies.includes('history') );
166
-
148
+ const controller = new ComputationController(config, deps);
149
+ const calcs = Object.values(state).filter(c => c && c.manifest);
150
+ const streamingCalcs = calcs.filter(c => c.manifest.rootDataDependencies.includes('portfolio') || c.manifest.rootDataDependencies.includes('history'));
167
151
  if (streamingCalcs.length === 0) return;
168
-
152
+
169
153
  logger.log('INFO', `[${passName}] Streaming for ${streamingCalcs.length} computations...`);
170
-
171
154
  await controller.loader.loadMappings();
172
- const prevDate = new Date(dateStr + 'T00:00:00Z'); prevDate.setUTCDate(prevDate.getUTCDate() - 1);
155
+ const prevDate = new Date(dateStr + 'T00:00:00Z'); prevDate.setUTCDate(prevDate.getUTCDate() - 1);
173
156
  const prevDateStr = prevDate.toISOString().slice(0, 10);
174
-
175
- const tP_iter = streamPortfolioData(config, deps, dateStr, portfolioRefs);
157
+ const tP_iter = streamPortfolioData(config, deps, dateStr, portfolioRefs);
176
158
  const needsYesterdayPortfolio = streamingCalcs.some(c => c.manifest.isHistorical);
177
- const yP_iter = (needsYesterdayPortfolio && rootData.yesterdayPortfolioRefs) ? streamPortfolioData(config, deps, prevDateStr, rootData.yesterdayPortfolioRefs) : null;
178
- const needsTradingHistory = streamingCalcs.some(c => c.manifest.rootDataDependencies.includes('history'));
179
- const tH_iter = (needsTradingHistory && historyRefs) ? streamHistoryData(config, deps, dateStr, historyRefs) : null;
180
-
181
- let yP_chunk = {};
182
- let tH_chunk = {};
159
+ const yP_iter = (needsYesterdayPortfolio && rootData.yesterdayPortfolioRefs) ? streamPortfolioData(config, deps, prevDateStr, rootData.yesterdayPortfolioRefs) : null;
160
+ const needsTradingHistory = streamingCalcs.some(c => c.manifest.rootDataDependencies.includes('history'));
161
+ const tH_iter = (needsTradingHistory && historyRefs) ? streamHistoryData(config, deps, dateStr, historyRefs) : null;
183
162
 
163
+ let yP_chunk = {}, tH_chunk = {};
184
164
  for await (const tP_chunk of tP_iter) {
185
165
  if (yP_iter) yP_chunk = (await yP_iter.next()).value || {};
186
166
  if (tH_iter) tH_chunk = (await tH_iter.next()).value || {};
187
-
188
- const promises = streamingCalcs.map(calc =>
189
- controller.executor.executePerUser(
190
- calc,
191
- calc.manifest,
192
- dateStr,
193
- tP_chunk,
194
- yP_chunk,
195
- tH_chunk,
196
- fetchedDeps,
197
- previousFetchedDeps
198
- )
199
- );
167
+ const promises = streamingCalcs.map(calc => controller.executor.executePerUser(calc, calc.manifest, dateStr, tP_chunk, yP_chunk, tH_chunk, fetchedDeps, previousFetchedDeps));
200
168
  await Promise.all(promises);
201
169
  }
202
170
  logger.log('INFO', `[${passName}] Streaming complete.`);
@@ -211,13 +179,8 @@ async function runStandardComputationPass(date, calcs, passName, config, deps, r
211
179
  const prevStr = prev.toISOString().slice(0, 10);
212
180
  fullRoot.yesterdayPortfolioRefs = await getPortfolioPartRefs(config, deps, prevStr);
213
181
  }
214
-
215
182
  const state = {};
216
- for (const c of calcs) {
217
- try { const inst = new c.class(); inst.manifest = c; state[normalizeName(c.name)] = inst; logger.log('INFO', `${c.name} calculation running for ${dStr}`); }
218
- catch (e) { logger.log('WARN', `Failed to init ${c.name}`); }
219
- }
220
-
183
+ for (const c of calcs) { try { const inst = new c.class(); inst.manifest = c; state[normalizeName(c.name)] = inst; logger.log('INFO', `${c.name} calculation running for ${dStr}`); } catch (e) { logger.log('WARN', `Failed to init ${c.name}`); } }
221
184
  await streamAndProcess(dStr, state, passName, config, deps, fullRoot, rootData.portfolioRefs, rootData.historyRefs, fetchedDeps, previousFetchedDeps);
222
185
  return await commitResults(state, dStr, passName, config, deps, skipStatusWrite);
223
186
  }
@@ -226,12 +189,10 @@ async function runMetaComputationPass(date, calcs, passName, config, deps, fetch
226
189
  const controller = new ComputationController(config, deps);
227
190
  const dStr = date.toISOString().slice(0, 10);
228
191
  const state = {};
229
-
230
192
  for (const mCalc of calcs) {
231
193
  try {
232
194
  deps.logger.log('INFO', `${mCalc.name} calculation running for ${dStr}`);
233
- const inst = new mCalc.class();
234
- inst.manifest = mCalc;
195
+ const inst = new mCalc.class(); inst.manifest = mCalc;
235
196
  await controller.executor.executeOncePerDay(inst, mCalc, dStr, fetchedDeps, previousFetchedDeps);
236
197
  state[normalizeName(mCalc.name)] = inst;
237
198
  } catch (e) { deps.logger.log('ERROR', `Meta calc failed ${mCalc.name}: ${e.message}`); }
@@ -241,15 +202,14 @@ async function runMetaComputationPass(date, calcs, passName, config, deps, fetch
241
202
 
242
203
  /**
243
204
  * --- REFACTORED: commitResults ---
244
- * Commits results individually per calculation.
245
- * If one calculation fails (e.g. size limit), others still succeed.
246
- * UPDATED: Writes the HASH to the status document.
205
+ * Automatically detects result size.
206
+ * If > 900KB, it splits the result into chunks and writes to a subcollection.
207
+ * If < 900KB, it writes normally.
247
208
  */
248
209
  async function commitResults(stateObj, dStr, passName, config, deps, skipStatusWrite = false) {
249
210
  const successUpdates = {};
250
211
  const schemas = [];
251
212
 
252
- // Iterate PER CALCULATION to isolate failures
253
213
  for (const name in stateObj) {
254
214
  const calc = stateObj[name];
255
215
  let hasData = false;
@@ -258,49 +218,30 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
258
218
  const result = await calc.getResult();
259
219
  if (!result) { deps.logger.log('INFO', `${name} for ${dStr}: Skipped (Empty Result)`); continue; }
260
220
 
261
- const standardRes = {};
262
- const shardedWrites = [];
263
- const calcWrites = [];
264
-
265
- // 1. Separate Standard and Sharded Data
266
- for (const key in result) {
267
- if (key.startsWith('sharded_')) {
268
- const sData = result[key];
269
- for (const colName in sData) {
270
- const docsMap = sData[colName];
271
- for (const docId in docsMap) { const ref = docId.includes('/') ? deps.db.doc(docId) : deps.db.collection(colName).doc(docId); shardedWrites.push({ ref, data: { ...docsMap[docId], _completed: true } }); } }
272
- if (Object.keys(sData).length > 0) hasData = true;
273
- } else { standardRes[key] = result[key]; }
221
+ const mainDocRef = deps.db.collection(config.resultsCollection).doc(dStr).collection(config.resultsSubcollection).doc(calc.manifest.category).collection(config.computationsSubcollection).doc(name);
222
+
223
+ // AUTO-SHARDING LOGIC
224
+ const updates = await prepareAutoShardedWrites(result, mainDocRef, deps.logger);
225
+
226
+ // Collect Schemas if present
227
+ if (calc.manifest.class.getSchema) {
228
+ const { class: _cls, ...safeMetadata } = calc.manifest;
229
+ schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata });
274
230
  }
275
231
 
276
- // 2. Prepare Standard Result Write
277
- if (Object.keys(standardRes).length) {
278
- validateResultPatterns(deps.logger, name, standardRes, calc.manifest.category);
279
- standardRes._completed = true;
280
- const docRef = deps.db.collection(config.resultsCollection).doc(dStr) .collection(config.resultsSubcollection).doc(calc.manifest.category) .collection(config.computationsSubcollection).doc(name);
281
- calcWrites.push({ ref: docRef, data: standardRes });
282
- hasData = true;
232
+ if (updates.length > 0) {
233
+ await commitBatchInChunks(config, deps, updates, `${name} Results`);
234
+ successUpdates[name] = calc.manifest.hash || true;
235
+ const isSharded = updates.some(u => u.data._sharded === true);
236
+ deps.logger.log('INFO', `${name} for ${dStr}: \u2714 Success (Written ${isSharded ? 'Sharded' : 'Standard'})`);
237
+ } else {
238
+ deps.logger.log('INFO', `${name} for ${dStr}: - Empty Data`);
283
239
  }
284
240
 
285
- // 3. Queue Schema (Safe to accumulate)
286
- if (calc.manifest.class.getSchema) { const { class: _cls, ...safeMetadata } = calc.manifest; schemas.push({ name, category: calc.manifest.category, schema: calc.manifest.class.getSchema(), metadata: safeMetadata }); }
287
-
288
- // 4. ATTEMPT COMMIT FOR THIS CALCULATION ONLY
289
- if (hasData) {
290
- const allWritesForCalc = [...calcWrites, ...shardedWrites];
291
- if (allWritesForCalc.length > 0) {
292
- await commitBatchInChunks(config, deps, allWritesForCalc, `${name} Results`);
293
- successUpdates[name] = calc.manifest.hash || true;
294
- deps.logger.log('INFO', `${name} for ${dStr}: \u2714 Success (Written)`);
295
- } else { deps.logger.log('INFO', `${name} for ${dStr}: - No Data to Write`); }
296
- } else { deps.logger.log('INFO', `${name} for ${dStr}: - Empty`); }
297
241
  } catch (e) { deps.logger.log('ERROR', `${name} for ${dStr}: \u2716 FAILED Commit: ${e.message}`); }
298
242
  }
299
243
 
300
- // Save Schemas (Best effort, isolated)
301
244
  if (schemas.length) batchStoreSchemas(deps, config, schemas).catch(() => { });
302
-
303
- // Update Status Document (Only for the ones that succeeded)
304
245
  if (!skipStatusWrite && Object.keys(successUpdates).length > 0) {
305
246
  await updateComputationStatus(dStr, successUpdates, config, deps);
306
247
  deps.logger.log('INFO', `[${passName}] Updated status document for ${Object.keys(successUpdates).length} successful computations.`);
@@ -309,116 +250,151 @@ async function commitResults(stateObj, dStr, passName, config, deps, skipStatusW
309
250
  }
310
251
 
311
252
  /**
312
- * --- UPDATED: runBatchPriceComputation ---
253
+ * Accurately calculates the size of a value according to Firestore storage rules.
254
+ * Reference: https://firebase.google.com/docs/firestore/storage-size
313
255
  */
256
+ function calculateFirestoreBytes(value) {
257
+ if (value === null) return 1;
258
+ if (value === undefined) return 0; // Firestore drops undefined fields
259
+ if (typeof value === 'boolean') return 1;
260
+ if (typeof value === 'number') return 8; // All numbers are 64-bit doubles or integers
261
+ if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1;
262
+ if (value instanceof Date) return 8; // Timestamps are 8 bytes
263
+
264
+ // Handle References (approximate based on path length)
265
+ if (value.constructor && value.constructor.name === 'DocumentReference') {
266
+ // Path string + 16 bytes for the reference type overhead
267
+ return Buffer.byteLength(value.path, 'utf8') + 16;
268
+ }
269
+
270
+ // Handle Arrays: Sum of all values
271
+ if (Array.isArray(value)) {
272
+ let sum = 0;
273
+ for (const item of value) sum += calculateFirestoreBytes(item);
274
+ return sum;
275
+ }
276
+
277
+ // Handle Objects (Maps): Sum of (Key + 1 + Value)
278
+ if (typeof value === 'object') {
279
+ let sum = 0;
280
+ for (const k in value) {
281
+ if (Object.prototype.hasOwnProperty.call(value, k)) {
282
+ // Key size (utf8 + 1) + Value size
283
+ sum += (Buffer.byteLength(k, 'utf8') + 1) + calculateFirestoreBytes(value[k]);
284
+ }
285
+ }
286
+ return sum;
287
+ }
288
+
289
+ return 0; // Fallback
290
+ }
291
+
292
+
293
+ async function prepareAutoShardedWrites(result, docRef, logger) {
294
+ const SAFETY_THRESHOLD_BYTES = 1000 * 1024; // 1MB Limit (We target just under this)
295
+ const OVERHEAD_ALLOWANCE = 20 * 1024; // 20KB Safety margin for document path & metadata
296
+ const CHUNK_LIMIT = SAFETY_THRESHOLD_BYTES - OVERHEAD_ALLOWANCE;
297
+ const totalSize = calculateFirestoreBytes(result); // 1. Calculate Total Size Once (O(N))
298
+ const docPathSize = Buffer.byteLength(docRef.path, 'utf8') + 16; // Add the size of the document path itself (Firestore counts this against the 1MB limit)
299
+
300
+ if ((totalSize + docPathSize) < CHUNK_LIMIT) { const data = { ...result, _completed: true, _sharded: false }; return [{ ref: docRef, data, options: { merge: true } }]; } // CASE A: Fits in one document
301
+
302
+ logger.log('INFO', `[AutoShard] Result size ~${Math.round(totalSize/1024)}KB exceeds limit. Sharding...`);
303
+
304
+ const writes = [];
305
+ const shardCollection = docRef.collection('_shards');
306
+
307
+ let currentChunk = {};
308
+ let currentChunkSize = 0;
309
+ let shardIndex = 0;
310
+
311
+
312
+ for (const [key, value] of Object.entries(result)) { // 2. Efficient O(N) Loop
313
+ if (key.startsWith('_')) continue;
314
+ const keySize = Buffer.byteLength(key, 'utf8') + 1; // Calculate size of just this item
315
+ const valueSize = calculateFirestoreBytes(value);
316
+ const itemSize = keySize + valueSize;
317
+
318
+ if (currentChunkSize + itemSize > CHUNK_LIMIT) { // Check if adding this item would overflow the current chunk
319
+ // Flush current chunk
320
+ writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } }); // Overwrite
321
+ shardIndex++;
322
+ currentChunk = {};
323
+ currentChunkSize = 0;
324
+ }
325
+
326
+ // Add to current chunk
327
+ currentChunk[key] = value;
328
+ currentChunkSize += itemSize;
329
+ }
330
+
331
+ // Flush final chunk
332
+ if (Object.keys(currentChunk).length > 0) { writes.push({ ref: shardCollection.doc(`shard_${shardIndex}`), data: currentChunk, options: { merge: false } }); }
333
+
334
+ // Pointer Document
335
+ const pointerData = { _completed: true, _sharded: true, _shardCount: shardIndex + 1, _lastUpdated: new Date().toISOString() };
336
+
337
+ // Use merge: false to ensure we overwrite any previous non-sharded blob
338
+ writes.push({ ref: docRef, data: pointerData, options: { merge: false } });
339
+
340
+ return writes;
341
+ }
342
+
314
343
  async function runBatchPriceComputation(config, deps, dateStrings, calcs, targetTickers = []) {
315
344
  const { logger, db, calculationUtils } = deps;
316
345
  const controller = new ComputationController(config, deps);
317
-
318
346
  const mappings = await controller.loader.loadMappings();
319
-
320
347
  let targetInstrumentIds = [];
321
348
  if (targetTickers && targetTickers.length > 0) {
322
349
  const tickerToInst = mappings.tickerToInstrument || {};
323
350
  targetInstrumentIds = targetTickers.map(t => tickerToInst[t]).filter(id => id);
324
- if (targetInstrumentIds.length === 0) { logger.log('WARN', '[BatchPrice] Target tickers provided but no IDs found. Aborting.'); return; } }
325
-
351
+ if (targetInstrumentIds.length === 0) { logger.log('WARN', '[BatchPrice] Target tickers provided but no IDs found. Aborting.'); return; }
352
+ }
326
353
  const allShardRefs = await getRelevantShardRefs(config, deps, targetInstrumentIds);
327
-
328
354
  if (!allShardRefs.length) { logger.log('WARN', '[BatchPrice] No relevant price shards found. Exiting.'); return; }
329
-
330
- const OUTER_CONCURRENCY_LIMIT = 2;
331
- const SHARD_BATCH_SIZE = 20;
332
- const WRITE_BATCH_LIMIT = 50;
333
-
355
+ const OUTER_CONCURRENCY_LIMIT = 2, SHARD_BATCH_SIZE = 20, WRITE_BATCH_LIMIT = 50;
334
356
  logger.log('INFO', `[BatchPrice] Execution Plan: ${dateStrings.length} days, ${allShardRefs.length} shards. Concurrency: ${OUTER_CONCURRENCY_LIMIT}.`);
335
-
336
- const shardChunks = [];
337
- for (let i = 0; i < allShardRefs.length; i += SHARD_BATCH_SIZE) { shardChunks.push(allShardRefs.slice(i, i + SHARD_BATCH_SIZE)); }
338
-
357
+ const shardChunks = []; for (let i = 0; i < allShardRefs.length; i += SHARD_BATCH_SIZE) { shardChunks.push(allShardRefs.slice(i, i + SHARD_BATCH_SIZE)); }
339
358
  const outerLimit = pLimit(OUTER_CONCURRENCY_LIMIT);
340
-
341
359
  const chunkPromises = [];
342
360
  for (let index = 0; index < shardChunks.length; index++) {
343
361
  const shardChunkRefs = shardChunks[index];
344
362
  chunkPromises.push(outerLimit(async () => {
345
363
  try {
346
364
  logger.log('INFO', `[BatchPrice] Processing chunk ${index + 1}/${shardChunks.length} (${shardChunkRefs.length} shards)...`);
347
-
348
365
  const pricesData = await loadDataByRefs(config, deps, shardChunkRefs);
349
-
350
- if (targetInstrumentIds.length > 0) {
351
- const requestedSet = new Set(targetInstrumentIds);
352
- for (const loadedInstrumentId in pricesData) { if (!requestedSet.has(loadedInstrumentId)) { delete pricesData[loadedInstrumentId]; } }
353
- }
354
-
366
+ if (targetInstrumentIds.length > 0) { const requestedSet = new Set(targetInstrumentIds); for (const loadedInstrumentId in pricesData) { if (!requestedSet.has(loadedInstrumentId)) { delete pricesData[loadedInstrumentId]; } } }
355
367
  const writes = [];
356
-
357
368
  for (const dateStr of dateStrings) {
358
-
359
- // --- DYNAMIC MATH CONTEXT CONSTRUCTION ---
360
369
  const dynamicMathContext = {};
361
- for (const [key, value] of Object.entries(mathLayer)) { dynamicMathContext[key] = value; if (LEGACY_MAPPING[key]) { dynamicMathContext[LEGACY_MAPPING[key]] = value;} }
362
-
363
- const context = {
364
- mappings,
365
- prices: { history: pricesData },
366
- date: { today: dateStr },
367
- math: dynamicMathContext // Injected here
368
- };
369
-
370
+ for (const [key, value] of Object.entries(mathLayer)) { dynamicMathContext[key] = value; if (LEGACY_MAPPING[key]) { dynamicMathContext[LEGACY_MAPPING[key]] = value;} }
371
+ const context = { mappings, prices: { history: pricesData }, date: { today: dateStr }, math: dynamicMathContext };
370
372
  for (const calcManifest of calcs) {
371
373
  try {
372
- const instance = new calcManifest.class();
373
- await instance.process(context);
374
- const result = await instance.getResult();
375
-
374
+ const instance = new calcManifest.class(); await instance.process(context); const result = await instance.getResult();
376
375
  if (result && Object.keys(result).length > 0) {
377
- let dataToWrite = result;
378
- if (result.by_instrument) dataToWrite = result.by_instrument;
379
-
376
+ let dataToWrite = result; if (result.by_instrument) dataToWrite = result.by_instrument;
380
377
  if (Object.keys(dataToWrite).length > 0) {
381
- const docRef = db.collection(config.resultsCollection).doc(dateStr) .collection(config.resultsSubcollection).doc(calcManifest.category) .collection(config.computationsSubcollection).doc(normalizeName(calcManifest.name));
382
-
378
+ const docRef = db.collection(config.resultsCollection).doc(dateStr).collection(config.resultsSubcollection).doc(calcManifest.category).collection(config.computationsSubcollection).doc(normalizeName(calcManifest.name));
383
379
  writes.push({ ref: docRef, data: { ...dataToWrite, _completed: true }, options: { merge: true } });
384
380
  }
385
381
  }
386
382
  } catch (err) { logger.log('ERROR', `[BatchPrice] \u2716 Failed ${calcManifest.name} for ${dateStr}: ${err.message}`); }
387
383
  }
388
384
  }
389
-
390
385
  if (writes.length > 0) {
391
- const commitBatches = [];
392
- for (let i = 0; i < writes.length; i += WRITE_BATCH_LIMIT) { commitBatches.push(writes.slice(i, i + WRITE_BATCH_LIMIT)); }
393
-
386
+ const commitBatches = []; for (let i = 0; i < writes.length; i += WRITE_BATCH_LIMIT) { commitBatches.push(writes.slice(i, i + WRITE_BATCH_LIMIT)); }
394
387
  const commitLimit = pLimit(10);
395
-
396
388
  await Promise.all(commitBatches.map((batchWrites, bIndex) => commitLimit(async () => {
397
- const batch = db.batch();
398
- batchWrites.forEach(w => batch.set(w.ref, w.data, w.options));
399
-
400
- try { await calculationUtils.withRetry(() => batch.commit(), `BatchPrice-C${index}-B${bIndex}`);
401
- } catch (commitErr) { logger.log('ERROR', `[BatchPrice] Commit failed for Chunk ${index} Batch ${bIndex}.`, { error: commitErr.message }); }
389
+ const batch = db.batch(); batchWrites.forEach(w => batch.set(w.ref, w.data, w.options));
390
+ try { await calculationUtils.withRetry(() => batch.commit(), `BatchPrice-C${index}-B${bIndex}`); } catch (commitErr) { logger.log('ERROR', `[BatchPrice] Commit failed for Chunk ${index} Batch ${bIndex}.`, { error: commitErr.message }); }
402
391
  })));
403
392
  }
404
-
405
393
  } catch (chunkErr) { logger.log('ERROR', `[BatchPrice] Fatal error processing Chunk ${index}.`, { error: chunkErr.message }); }
406
394
  }));
407
395
  }
408
-
409
396
  await Promise.all(chunkPromises);
410
397
  logger.log('INFO', '[BatchPrice] Optimization pass complete.');
411
398
  }
412
399
 
413
- module.exports = {
414
- groupByPass,
415
- checkRootDependencies,
416
- checkRootDataAvailability,
417
- fetchExistingResults,
418
- fetchComputationStatus,
419
- fetchGlobalComputationStatus,
420
- updateComputationStatus,
421
- runStandardComputationPass,
422
- runMetaComputationPass,
423
- runBatchPriceComputation
424
- };
400
+ module.exports = { groupByPass, checkRootDependencies, checkRootDataAvailability, fetchExistingResults, fetchComputationStatus, fetchGlobalComputationStatus, updateComputationStatus, runStandardComputationPass, runMetaComputationPass, runBatchPriceComputation };
@@ -1,8 +1,5 @@
1
1
  /**
2
- * @fileoverview Computation system sub-pipes and utils.
3
- * REFACTORED: Now stateless and receive dependencies where needed.
4
- * FIXED: 'commitBatchInChunks' now respects Firestore 10MB size limit.
5
- * NEW: Added 'generateCodeHash' for version control.
2
+ * FILENAME: computation-system/utils/utils.js
6
3
  */
7
4
 
8
5
  const { FieldValue, FieldPath } = require('@google-cloud/firestore');
@@ -12,27 +9,18 @@ const crypto = require('crypto');
12
9
  function normalizeName(name) { return name.replace(/_/g, '-'); }
13
10
 
14
11
  /**
15
- * Generates a SHA-256 hash of a code string, ignoring comments and whitespace.
16
- * This effectively versions the logic.
17
- * @param {string} codeString - The source code of the function/class.
18
- * @returns {string} The hex hash.
12
+ * Generates a SHA-256 hash of a code string.
19
13
  */
20
14
  function generateCodeHash(codeString) {
21
15
  if (!codeString) return 'unknown';
22
-
23
- // 1. Remove single-line comments (//...)
24
16
  let clean = codeString.replace(/\/\/.*$/gm, '');
25
- // 2. Remove multi-line comments (/*...*/)
26
17
  clean = clean.replace(/\/\*[\s\S]*?\*\//g, '');
27
- // 3. Remove all whitespace (spaces, tabs, newlines)
28
18
  clean = clean.replace(/\s+/g, '');
29
-
30
19
  return crypto.createHash('sha256').update(clean).digest('hex');
31
20
  }
32
21
 
33
22
  /** * Stage 2: Commit a batch of writes in chunks
34
- * FIXED: Now splits batches by SIZE (9MB limit) and COUNT (450 docs)
35
- * to prevent "Request payload size exceeds the limit" errors.
23
+ * FIXED: Now respects write.options (e.g. { merge: false }) to allow overwrites/deletes.
36
24
  */
37
25
  async function commitBatchInChunks(config, deps, writes, operationName) {
38
26
  const { db, logger, calculationUtils } = deps;
@@ -43,17 +31,14 @@ async function commitBatchInChunks(config, deps, writes, operationName) {
43
31
  return;
44
32
  }
45
33
 
46
- // Firestore Constraints
47
- const MAX_BATCH_OPS = 300; // Safety limit (Max 500)
48
- const MAX_BATCH_BYTES = 9 * 1024 * 1024; // 9MB Safety limit (Max 10MB)
34
+ const MAX_BATCH_OPS = 300;
35
+ const MAX_BATCH_BYTES = 9 * 1024 * 1024;
49
36
 
50
37
  let currentBatch = db.batch();
51
38
  let currentOpsCount = 0;
52
39
  let currentBytesEst = 0;
53
40
  let batchIndex = 1;
54
- let totalChunks = 0; // We don't know total chunks in advance now due to dynamic sizing
55
41
 
56
- // Helper to commit the current batch and reset
57
42
  const commitAndReset = async () => {
58
43
  if (currentOpsCount > 0) {
59
44
  try {
@@ -74,30 +59,25 @@ async function commitBatchInChunks(config, deps, writes, operationName) {
74
59
  };
75
60
 
76
61
  for (const write of writes) {
77
- // 1. Estimate Size: JSON stringify is a decent proxy for Firestore payload size
78
- // We handle potential circular refs or failures gracefully by assuming a minimum size
79
62
  let docSize = 100;
80
- try {
81
- if (write.data) docSize = JSON.stringify(write.data).length;
82
- } catch (e) { /* ignore size check error */ }
63
+ try { if (write.data) docSize = JSON.stringify(write.data).length; } catch (e) { }
83
64
 
84
- // 2. Warn if a SINGLE document is approaching the 1MB limit
85
65
  if (docSize > 900 * 1024) {
86
- logger.log('WARN', `[${operationName}] Large document detected (~${(docSize / 1024).toFixed(2)} KB). This allows few ops per batch.`);
66
+ logger.log('WARN', `[${operationName}] Large document detected (~${(docSize / 1024).toFixed(2)} KB).`);
87
67
  }
88
68
 
89
- // 3. Check if adding this write would overflow the batch
90
69
  if ((currentOpsCount + 1 > MAX_BATCH_OPS) || (currentBytesEst + docSize > MAX_BATCH_BYTES)) {
91
70
  await commitAndReset();
92
71
  }
93
72
 
94
- // 4. Add to batch
95
- currentBatch.set(write.ref, write.data, { merge: true });
73
+ // USE PROVIDED OPTIONS OR DEFAULT TO MERGE: TRUE
74
+ const options = write.options || { merge: true };
75
+ currentBatch.set(write.ref, write.data, options);
76
+
96
77
  currentOpsCount++;
97
78
  currentBytesEst += docSize;
98
79
  }
99
80
 
100
- // 5. Commit remaining
101
81
  await commitAndReset();
102
82
  }
103
83
 
@@ -112,10 +92,7 @@ function getExpectedDateStrings(startDate, endDate) {
112
92
  return dateStrings;
113
93
  }
114
94
 
115
- /**
116
- * --- NEW HELPER ---
117
- * Stage 4: Get the earliest date in a *flat* collection where doc IDs are dates.
118
- */
95
+ /** Stage 4: Get the earliest date in a *flat* collection where doc IDs are dates. */
119
96
  async function getFirstDateFromSimpleCollection(config, deps, collectionName) {
120
97
  const { db, logger, calculationUtils } = deps;
121
98
  const { withRetry } = calculationUtils;
@@ -149,22 +126,10 @@ async function getFirstDateFromCollection(config, deps, collectionName) {
149
126
  return earliestDate;
150
127
  }
151
128
 
152
- /** * --- MODIFIED FUNCTION ---
153
- * Stage 5: Determine the earliest date from *all* source data.
154
- */
129
+ /** Stage 5: Determine the earliest date from *all* source data. */
155
130
  async function getEarliestDataDates(config, deps) {
156
131
  const { logger } = deps;
157
- logger.log('INFO', 'Querying for earliest date from ALL source data collections...');
158
-
159
- const [
160
- investorDate,
161
- speculatorDate,
162
- investorHistoryDate,
163
- speculatorHistoryDate,
164
- insightsDate,
165
- socialDate,
166
- priceDate
167
- ] = await Promise.all([
132
+ const [ investorDate, speculatorDate, investorHistoryDate, speculatorHistoryDate, insightsDate, socialDate, priceDate ] = await Promise.all([
168
133
  getFirstDateFromCollection(config, deps, config.normalUserPortfolioCollection),
169
134
  getFirstDateFromCollection(config, deps, config.speculatorPortfolioCollection),
170
135
  getFirstDateFromCollection(config, deps, config.normalUserHistoryCollection),
@@ -185,17 +150,11 @@ async function getEarliestDataDates(config, deps) {
185
150
  const earliestInsightsDate = getMinDate(insightsDate);
186
151
  const earliestSocialDate = getMinDate(socialDate);
187
152
  const earliestPriceDate = getMinDate(priceDate);
188
- const absoluteEarliest = getMinDate(
189
- earliestPortfolioDate,
190
- earliestHistoryDate,
191
- earliestInsightsDate,
192
- earliestSocialDate,
193
- earliestPriceDate
194
- );
153
+ const absoluteEarliest = getMinDate(earliestPortfolioDate, earliestHistoryDate, earliestInsightsDate, earliestSocialDate, earliestPriceDate);
195
154
 
196
155
  const fallbackDate = new Date(config.earliestComputationDate + 'T00:00:00Z' || '2023-01-01T00:00:00Z');
197
156
 
198
- const result = {
157
+ return {
199
158
  portfolio: earliestPortfolioDate || new Date('2999-12-31T00:00:00Z'),
200
159
  history: earliestHistoryDate || new Date('2999-12-31T00:00:00Z'),
201
160
  insights: earliestInsightsDate || new Date('2999-12-31T00:00:00Z'),
@@ -203,71 +162,26 @@ async function getEarliestDataDates(config, deps) {
203
162
  price: earliestPriceDate || new Date('2999-12-31T00:00:00Z'),
204
163
  absoluteEarliest: absoluteEarliest || fallbackDate
205
164
  };
206
-
207
- logger.log('INFO', 'Earliest data availability map built:', {
208
- portfolio: result.portfolio.toISOString().slice(0, 10),
209
- history: result.history.toISOString().slice(0, 10),
210
- insights: result.insights.toISOString().slice(0, 10),
211
- social: result.social.toISOString().slice(0, 10),
212
- price: result.price.toISOString().slice(0, 10),
213
- absoluteEarliest: result.absoluteEarliest.toISOString().slice(0, 10)
214
- });
215
-
216
- return result;
217
165
  }
218
166
 
219
- /**
220
- * NEW HELPER: Get the earliest date from price collection
221
- */
222
167
  async function getFirstDateFromPriceCollection(config, deps) {
223
168
  const { db, logger, calculationUtils } = deps;
224
169
  const { withRetry } = calculationUtils;
225
170
  const collection = config.priceCollection || 'asset_prices';
226
-
227
171
  try {
228
- logger.log('TRACE', `[getFirstDateFromPriceCollection] Querying ${collection}...`);
229
-
230
- const snapshot = await withRetry(
231
- () => db.collection(collection).limit(10).get(),
232
- `GetPriceShards(${collection})`
233
- );
234
-
235
- if (snapshot.empty) {
236
- logger.log('WARN', `No price shards found in ${collection}`);
237
- return null;
238
- }
239
-
172
+ const snapshot = await withRetry(() => db.collection(collection).limit(10).get(), `GetPriceShards(${collection})`);
240
173
  let earliestDate = null;
241
-
242
174
  snapshot.forEach(doc => {
243
175
  const shardData = doc.data();
244
176
  for (const instrumentId in shardData) {
245
177
  const instrumentData = shardData[instrumentId];
246
178
  if (!instrumentData.prices) continue;
247
-
248
- const dates = Object.keys(instrumentData.prices)
249
- .filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
250
- .sort();
251
-
252
- if (dates.length > 0) {
253
- const firstDate = new Date(dates[0] + 'T00:00:00Z');
254
- if (!earliestDate || firstDate < earliestDate) {
255
- earliestDate = firstDate;
256
- }
257
- }
179
+ const dates = Object.keys(instrumentData.prices).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort();
180
+ if (dates.length > 0) { const firstDate = new Date(dates[0] + 'T00:00:00Z'); if (!earliestDate || firstDate < earliestDate) earliestDate = firstDate; }
258
181
  }
259
182
  });
260
-
261
- if (earliestDate) {
262
- logger.log('TRACE', `[getFirstDateFromPriceCollection] Earliest price date: ${earliestDate.toISOString().slice(0, 10)}`);
263
- }
264
-
265
183
  return earliestDate;
266
-
267
- } catch (e) {
268
- logger.log('ERROR', `Failed to get earliest price date from ${collection}`, { errorMessage: e.message });
269
- return null;
270
- }
184
+ } catch (e) { logger.log('ERROR', `Failed to get earliest price date from ${collection}`, { errorMessage: e.message }); return null; }
271
185
  }
272
186
 
273
187
  module.exports = { FieldValue, FieldPath, normalizeName, commitBatchInChunks, getExpectedDateStrings, getEarliestDataDates, generateCodeHash };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.215",
3
+ "version": "1.0.216",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [