bulltrackers-module 1.0.807 → 1.0.809

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.
Files changed (21) hide show
  1. package/functions/alert-system-v3/handlers/ingest.js +2 -5
  2. package/functions/computation-system-v3/config/bulltrackers.config.js +4 -2
  3. package/functions/computation-system-v3/framework/adapters/StateRepository.js +24 -40
  4. package/functions/computation-system-v3/framework/core/Coordinator.js +17 -14
  5. package/functions/computation-system-v3/framework/scheduling/TaskScheduler.js +38 -20
  6. package/functions/computation-system-v3/framework/tests/unit/output/BehavioralAnomaly.json +4 -0
  7. package/functions/computation-system-v3/framework/tests/unit/output/GlobalAumPerAsset30D.json +3 -0
  8. package/functions/computation-system-v3/framework/tests/unit/output/NewSectorExposure.json +4 -0
  9. package/functions/computation-system-v3/framework/tests/unit/output/NewSocialPost.json +5 -0
  10. package/functions/computation-system-v3/framework/tests/unit/output/PIDailyAssetAUM.json +4 -0
  11. package/functions/computation-system-v3/framework/tests/unit/output/PiFeatureVectors.json +22 -0
  12. package/functions/computation-system-v3/framework/tests/unit/output/PiRecommender.json +4 -0
  13. package/functions/computation-system-v3/framework/tests/unit/output/PopularInvestorProfileMetrics.json +84 -0
  14. package/functions/computation-system-v3/framework/tests/unit/output/PositionInvestedIncrease.json +4 -0
  15. package/functions/computation-system-v3/framework/tests/unit/output/RiskScoreIncrease.json +4 -0
  16. package/functions/computation-system-v3/framework/tests/unit/output/SectorCorrelations.json +6 -0
  17. package/functions/computation-system-v3/framework/tests/unit/output/SignedInUserMirrorHistory.json +6 -0
  18. package/functions/computation-system-v3/framework/tests/unit/output/SignedInUserPIProfileMetrics.json +4 -0
  19. package/functions/computation-system-v3/framework/tests/unit/output/SignedInUserProfileMetrics.json +67 -0
  20. package/functions/computation-system-v3/handlers/dispatcher.js +54 -14
  21. package/package.json +3 -2
@@ -11,11 +11,8 @@ async function handleComputationResultWrite(change, context, config, dependencie
11
11
  const { logger } = ctx;
12
12
 
13
13
  try {
14
- // 1. Extract parameters from the context (provided by the Wildcard Trigger)
15
- const { date, computationName, documentId } = context.params || {};
16
-
17
- logger?.log('INFO', `[AlertV3/Ingest] Triggered! Params: ${JSON.stringify(context.params)} Path: ${change?.after?.ref?.path}`);
18
-
14
+ // 1. Extract parameters from the context
15
+ let { date, computationName, documentId } = context.params || {};
19
16
 
20
17
  // 2. Load Alert Types if not cached (Cold Start Handling)
21
18
  if (!cachedAlertTypes) {
@@ -43,8 +43,8 @@ module.exports = {
43
43
  // =========================================================================
44
44
  tables: {
45
45
  // System Tables
46
- state: { table: 'computation_state', dataset: 'bulltrackers_system' },
47
- results: { table: 'computation_results_v3', dataset: 'bulltrackers_analytics' },
46
+ state: { table: 'computation_state', dataset: 'bulltrackers_data' },
47
+ results: { table: 'computation_results_v3', dataset: 'bulltrackers_data' },
48
48
 
49
49
  // Data Sources (Matches V2)
50
50
  'portfolio_snapshots': {
@@ -126,6 +126,8 @@ module.exports = {
126
126
  time: '02:00',
127
127
  timezone: 'UTC'
128
128
  },
129
+ // [FIX] -1 prevents "Today" from being scheduled
130
+ maxSchedulingDateOffset: -1,
129
131
  dependencyGapMinutes: 5
130
132
  },
131
133
 
@@ -31,7 +31,6 @@ class StateRepository {
31
31
  const fullResultsTable = `\`${this.config.bigquery.projectId}.${this.config.bigquery.dataset}.${this.resultsTable}\``;
32
32
  const query = `SELECT entity_id, result_data FROM ${fullResultsTable} WHERE date = CAST(@date AS DATE) AND computation_name = @comp AND entity_id IN UNNEST(@ids)`;
33
33
 
34
- // [FIX] Removed try/catch. If BQ fails, we must fail.
35
34
  const [rows] = await this.bigquery.query({ query, params: { date: dateStr, comp: computationName, ids: entityIds }, location: this.config.bigquery.location });
36
35
  const resultMap = {};
37
36
  for (const row of rows) {
@@ -46,7 +45,6 @@ class StateRepository {
46
45
  const fullResultsTable = `\`${this.config.bigquery.projectId}.${this.config.bigquery.dataset}.${this.resultsTable}\``;
47
46
  const query = `SELECT result_data FROM ${fullResultsTable} WHERE date = CAST(@date AS DATE) AND computation_name = @comp AND entity_id = '_global' LIMIT 1`;
48
47
 
49
- // [FIX] Removed try/catch
50
48
  const [rows] = await this.bigquery.query({ query, params: { date: dateStr, comp: computationName }, location: this.config.bigquery.location });
51
49
  if (rows.length === 0) return null;
52
50
  let data = rows[0].result_data;
@@ -55,31 +53,29 @@ class StateRepository {
55
53
  }
56
54
 
57
55
  async getDailyStatus(dateStr) {
58
- try {
59
- const snapshot = await this.firestore.collectionGroup('runs')
60
- .where('date', '==', dateStr)
61
- .get();
56
+ // [FIX] REMOVED try/catch.
57
+ // If Firestore is down, we WANT this to throw.
58
+ // Returning an empty Map causes the Planner to believe ALL history is missing,
59
+ // triggering catastrophic backfills.
60
+
61
+ const snapshot = await this.firestore.collectionGroup('runs')
62
+ .where('date', '==', dateStr)
63
+ .get();
62
64
 
63
- const map = new Map();
64
- snapshot.forEach(doc => {
65
- const data = doc.data();
66
- const computationName = data.computationName || doc.ref.parent.parent.id;
67
-
68
- map.set(computationName, {
69
- status: data.status,
70
- hash: data.codeHash,
71
- resultHash: data.resultHash,
72
- timestamp: data.lastUpdated ? data.lastUpdated.toMillis() : Date.now(),
73
- metadata: data.metadata || {}
74
- });
65
+ const map = new Map();
66
+ snapshot.forEach(doc => {
67
+ const data = doc.data();
68
+ const computationName = data.computationName || doc.ref.parent.parent.id;
69
+
70
+ map.set(computationName, {
71
+ status: data.status,
72
+ hash: data.codeHash,
73
+ resultHash: data.resultHash,
74
+ timestamp: data.lastUpdated ? data.lastUpdated.toMillis() : Date.now(),
75
+ metadata: data.metadata || {}
75
76
  });
76
- return map;
77
- } catch (e) {
78
- // Control plane failure is acceptable to catch if we want to fallback,
79
- // but warned loudly.
80
- this.logger.warn(`[StateRepo] Failed to get daily status (FS): ${e.message}`);
81
- return new Map();
82
- }
77
+ });
78
+ return map;
83
79
  }
84
80
 
85
81
  async updateStatus({ computation, date, status, hash, resultHash, metadata = {} }) {
@@ -129,7 +125,7 @@ class StateRepository {
129
125
  lastUpdated: new Date(),
130
126
  startedAt: metadata.startedAt ? new Date(metadata.startedAt) : new Date(),
131
127
  totalBatches: metadata.totalBatches || 0,
132
- finalized: false // [FIX] Initialize finalized flag for race-condition prevention
128
+ finalized: false
133
129
  };
134
130
 
135
131
  await docRef.set(data, { merge: true });
@@ -161,17 +157,11 @@ class StateRepository {
161
157
 
162
158
  return { completedBatches: snapshot.data().count };
163
159
  } catch (e) {
164
- // [FIX] Log the error!
165
160
  this.logger.error(`[StateRepo] getCheckpointProgress failed for ${checkpointId}: ${e.message}`);
166
161
  return { completedBatches: 0 };
167
162
  }
168
163
  }
169
164
 
170
- /**
171
- * [FIX] Atomic Transaction to Claim Finalization Rights.
172
- * Prevents multiple workers from triggering finalization simultaneously.
173
- * @returns {Promise<boolean>} true if claim successful, false otherwise.
174
- */
175
165
  async claimFinalization(checkpointId, requiredTotalBatches) {
176
166
  const checkpointRef = this.firestore.collection(this.collections.checkpoints).doc(checkpointId);
177
167
 
@@ -182,12 +172,10 @@ class StateRepository {
182
172
 
183
173
  const data = doc.data();
184
174
 
185
- // 1. Check if already finalized or finalizing
186
175
  if (data.finalized || data.status === 'finalizing') {
187
176
  return false;
188
177
  }
189
178
 
190
- // 2. Check if actually complete (Double check inside transaction)
191
179
  const batchesSnap = await t.get(
192
180
  this.firestore.collection(this.collections.checkpoints)
193
181
  .doc(checkpointId)
@@ -198,13 +186,12 @@ class StateRepository {
198
186
  const currentCount = batchesSnap.data().count;
199
187
 
200
188
  if (currentCount < requiredTotalBatches) {
201
- return false; // Still pending
189
+ return false;
202
190
  }
203
191
 
204
- // 3. Claim it
205
192
  t.update(checkpointRef, {
206
193
  status: 'finalizing',
207
- finalized: true, // Mark as claimed
194
+ finalized: true,
208
195
  finalizationStartedAt: new Date()
209
196
  });
210
197
 
@@ -309,7 +296,6 @@ class StateRepository {
309
296
  async findZombies(thresholdMinutes) {
310
297
  const thresholdDate = new Date(Date.now() - (thresholdMinutes * 60000));
311
298
  try {
312
- // NOTE: Requires Composite Index on (status, lastUpdated)
313
299
  const snapshot = await this.firestore.collection(this.collections.checkpoints)
314
300
  .where('status', '==', 'running')
315
301
  .where('lastUpdated', '<', thresholdDate)
@@ -328,7 +314,6 @@ class StateRepository {
328
314
  });
329
315
  return zombies;
330
316
  } catch (e) {
331
- // [FIX] Do NOT fail silently. If the DB is down, we must know.
332
317
  this.logger.error(`[StateRepo] CRITICAL: findZombies failed: ${e.message}`);
333
318
  throw e;
334
319
  }
@@ -339,7 +324,6 @@ class StateRepository {
339
324
  await docRef.set({
340
325
  status: 'recovering',
341
326
  lastUpdated: new Date(),
342
- // Uses atomic increment to track retry count
343
327
  attempts: FieldValue.increment(1)
344
328
  }, { merge: true });
345
329
  }
@@ -5,6 +5,7 @@
5
5
  * FIX: Passes computationName to BQ Adapter for better logging.
6
6
  * FIX: Solved Race Condition in Finalization.
7
7
  * FIX: Solved Hardcoded Failure Thresholds.
8
+ * FIX: Persists 'blocked' status to prevent planner loops.
8
9
  */
9
10
 
10
11
  const { RunAnalyzer } = require('./RunAnalyzer');
@@ -48,6 +49,18 @@ class Coordinator {
48
49
 
49
50
  if (analysis.status === 'blocked') {
50
51
  this.logger.log(`[Coordinator] Blocked: ${analysis.reason}`);
52
+
53
+ // [FIX] Update state to 'blocked' so Planner knows it's handled.
54
+ if (!dryRun) {
55
+ await this.stateRepo.updateStatus({
56
+ computation: entry.name,
57
+ date,
58
+ status: 'blocked',
59
+ hash: entry.hash,
60
+ metadata: { blockedReason: analysis.reason }
61
+ });
62
+ }
63
+
51
64
  return { status: 'blocked', reason: analysis.reason };
52
65
  }
53
66
 
@@ -122,9 +135,9 @@ class Coordinator {
122
135
 
123
136
  if (failures.length > 0 && !dryRun && this.stateRepo.logBatchErrors) { await this.stateRepo.logBatchErrors(checkpointId || 'unknown-checkpoint', failures); }
124
137
 
125
- // [FIX] Configurable Failure Thresholds (Instead of hardcoded 10%)
126
- const maxFailureRate = this.config.execution?.maxFailureRate ?? 0.15; // Default 15%
127
- const minFailuresForAbort = this.config.execution?.minFailuresForAbort ?? 5; // Minimum failures before aborting
138
+ // [FIX] Configurable Failure Thresholds
139
+ const maxFailureRate = this.config.execution?.maxFailureRate ?? 0.15;
140
+ const minFailuresForAbort = this.config.execution?.minFailuresForAbort ?? 5;
128
141
 
129
142
  const failureRate = failures.length / entityIds.length;
130
143
  if (entityIds.length > 0 && failures.length >= minFailuresForAbort && failureRate > maxFailureRate) {
@@ -162,14 +175,12 @@ class Coordinator {
162
175
 
163
176
  async tryFinalizeComputation({ computationName, date, checkpointId, totalBatches }) {
164
177
  // [FIX] Use Atomic Claim Transaction instead of Check-Then-Act
165
- // This prevents multiple workers from winning the race to finalize
166
178
  const claimed = await this.stateRepo.claimFinalization(checkpointId, totalBatches);
167
179
 
168
180
  if (claimed) {
169
181
  return await this.finalizeComputation({ computationName, date, checkpointId });
170
182
  }
171
183
 
172
- // If not claimed, check if it's because it's still pending or already done
173
184
  const progress = await this.stateRepo.getCheckpointProgress(checkpointId);
174
185
  return { status: 'pending_or_finalized', progress: `${progress.completedBatches}/${totalBatches}` };
175
186
  }
@@ -178,14 +189,11 @@ class Coordinator {
178
189
  const entry = this.manifestMap.get(computationName.toLowerCase());
179
190
  this.logger.log(`[Coordinator] Finalizing ${entry.name} for ${date}...`);
180
191
 
181
- const start = Date.now(); // Track finalization timing
182
192
  const dailyStatus = await this.stateRepo.getDailyStatus(date);
183
193
  const previousRun = dailyStatus.get(entry.name);
184
194
 
185
- // Finalize storage (merge results)
186
195
  const { resultHash } = await this.storage.finalizeResults(date, entry) || {};
187
196
 
188
- // Update State
189
197
  await this.stateRepo.updateStatus({
190
198
  computation: entry.name,
191
199
  date,
@@ -195,14 +203,11 @@ class Coordinator {
195
203
  metadata: { checkpointId, finalizedAt: new Date() }
196
204
  });
197
205
 
198
- // [ADD] Discord Alert
199
206
  if (this.notifier) {
200
- // Calculate total duration if we have metadata, otherwise just use current op
201
- const duration = 0; // Simple placeholder, or calculate from checkpoint start time
207
+ const duration = 0;
202
208
  this.notifier.reportSuccess(entry.name, date, duration, resultHash);
203
209
  }
204
210
 
205
- // Trigger Cascades
206
211
  if (resultHash && (!previousRun || resultHash !== previousRun.resultHash)) {
207
212
  await this._triggerCascading(entry, date);
208
213
  }
@@ -232,7 +237,6 @@ class Coordinator {
232
237
  const checkpointId = `${entry.name}-${date}-${Date.now()}`;
233
238
  let batchCounter = 0;
234
239
 
235
- // [FIX] Added hash to task name to prevent collision on re-runs
236
240
  const configHash = entry.hash ? entry.hash.substring(0, 8) : 'nohash';
237
241
 
238
242
  const createBatchTask = (batchIds) => {
@@ -365,7 +369,6 @@ class Coordinator {
365
369
  const totalBatches = batchCounter;
366
370
 
367
371
  if (remainingBatches.length === 0) {
368
- // [ADD] Explicit Diagnostic Log
369
372
  this.logger.log(`[Coordinator] [Recovery] Diagnosis for ${entry.name}: All ${totalBatches} batches complete. Missing Finalizer. Triggering immediately.`);
370
373
 
371
374
  await this._triggerTryFinalizeTask(entry, date, checkpointId, 0, totalBatches, true);
@@ -4,6 +4,8 @@
4
4
  * - Allows any pass to be scheduled if Hash Mismatch is detected.
5
5
  * - Enforces execution order via ETAs: Pass N runs 10 mins after Pass N-1.
6
6
  * - FIX: Enforces UTC date arithmetic.
7
+ * - FIX: T-1 Scheduling Logic.
8
+ * - FIX: Handles 'blocked' status properly.
7
9
  */
8
10
 
9
11
  const { ScheduleValidator } = require('./ScheduleValidator');
@@ -22,10 +24,10 @@ class TaskScheduler {
22
24
  this.LOOKBACK_DAYS = config.planningLookbackDays ?? 7;
23
25
  this.LOOKAHEAD_HOURS = config.planningLookaheadHours ?? 24;
24
26
  this.ZOMBIE_THRESHOLD_MINUTES = config.zombieThresholdMinutes ?? 15;
25
-
26
- // 10 Minute buffer per pass level to ensure upstream dependencies
27
- // have time to finish (or trigger cascades) before downstream runs.
28
27
  this.PASS_DELAY_SECONDS = config.passDelaySeconds ?? 600;
28
+
29
+ // [FIX] Default to -1 (Yesterday) to enforce T-1 rule
30
+ this.MAX_DATE_OFFSET = config.scheduling?.maxSchedulingDateOffset ?? -1;
29
31
  }
30
32
 
31
33
  async planComputations(referenceDate = new Date()) {
@@ -41,6 +43,7 @@ class TaskScheduler {
41
43
 
42
44
  this.logger.log(`[Planner] Reconciling: ${windowStart.toISOString()} to ${windowEnd.toISOString()}`);
43
45
 
46
+ // [FIX] Use Date Generator that respects MAX_DATE_OFFSET
44
47
  const targetDates = this._generateDateRange(windowStart, windowEnd);
45
48
  const tasksToSchedule = new Map();
46
49
  const stats = { checked: 0, missing: 0, mismatched: 0, scheduled: 0, exists: 0, deleted: 0 };
@@ -50,20 +53,22 @@ class TaskScheduler {
50
53
  const dailyStatus = await this.stateRepo.getDailyStatus(dateStr);
51
54
 
52
55
  for (const entry of this.manifest) {
53
- // [UPDATE] Removed "entry.pass !== 1" check.
54
- // We now evaluate ALL passes for potential fixes/backfills.
55
-
56
56
  const effectiveSchedule = this.validator.parseSchedule(entry.schedule);
57
57
  if (!this.validator.shouldRunOnDate(effectiveSchedule, dateObj)) continue;
58
58
 
59
59
  stats.checked++;
60
60
  const statusEntry = dailyStatus instanceof Map ? dailyStatus.get(entry.name) : dailyStatus[entry.name];
61
+
61
62
  const lastRunHash = statusEntry?.hash;
62
63
  const rawStatus = statusEntry?.status;
63
64
  const status = rawStatus ? rawStatus.toLowerCase() : null;
64
65
 
65
66
  let reason = null;
66
67
 
68
+ // [FIX] Logic to handle 'blocked' status.
69
+ // If it is 'blocked', we generally treat it as handled (do nothing).
70
+ // It will only be retried if it is truly missing or failed.
71
+
67
72
  if (!statusEntry || status === 'pending') {
68
73
  reason = 'MISSING_RUN';
69
74
  stats.missing++;
@@ -72,7 +77,8 @@ class TaskScheduler {
72
77
  reason = 'RETRY_FAILED';
73
78
  stats.missing++;
74
79
  }
75
- else if (lastRunHash !== entry.hash && status !== 'running') {
80
+ // Check mismatch only if not running and not blocked (blocked means we are waiting for something else)
81
+ else if (lastRunHash !== entry.hash && status !== 'running' && status !== 'blocked') {
76
82
  reason = 'HASH_MISMATCH';
77
83
  stats.mismatched++;
78
84
  }
@@ -81,17 +87,13 @@ class TaskScheduler {
81
87
  const taskKey = `root-${entry.name}-${dateStr}-${entry.hash}`;
82
88
 
83
89
  if (!tasksToSchedule.has(taskKey)) {
84
- // 1. Calculate Base Window (When it SHOULD run according to schedule)
90
+ // 1. Calculate Base Window
85
91
  const baseRunAt = this._getNextRunWindow(effectiveSchedule, dateObj);
86
92
 
87
93
  // 2. Calculate Topological Delay
88
- // If Pass 1 runs at T, Pass 2 runs at T + 10m, Pass 3 at T + 20m.
89
- // This allows Pass 1 to finish and trigger a natural Cascade for Pass 2
90
- // BEFORE the scheduled Pass 2 task fires (avoiding redundancy/conflicts).
91
94
  const passDelay = (entry.pass - 1) * this.PASS_DELAY_SECONDS;
92
95
 
93
96
  // 3. Determine Final Execution Time
94
- // If baseRunAt is 0 (meaning "ASAP" / window passed), we base delay on NOW.
95
97
  const nowSeconds = Math.floor(Date.now() / 1000);
96
98
  const effectiveBase = baseRunAt === 0 ? nowSeconds : baseRunAt;
97
99
  const runAtSeconds = effectiveBase + passDelay;
@@ -138,7 +140,6 @@ class TaskScheduler {
138
140
 
139
141
  /**
140
142
  * Watchdog: Find and recover zombie tasks.
141
- * UPDATED: Now diagnoses the cause of the zombie state.
142
143
  */
143
144
  async runWatchdog() {
144
145
  if (!this.stateRepo.findZombies) {
@@ -153,17 +154,16 @@ class TaskScheduler {
153
154
 
154
155
  this.logger.log(`[Watchdog] Found ${recoverable.length} zombies.`);
155
156
 
156
- // [ADD] Diagnostic Phase: Inspect checkpoints to see WHY they are zombies
157
+ // [ADD] Diagnostic Phase
157
158
  const diagnostics = await Promise.all(recoverable.map(async z => {
158
159
  try {
159
- // Use existing repo methods to peek at state
160
160
  const progress = await this.stateRepo.getCheckpointProgress(z.checkpointId);
161
161
  const checkpoint = await this.stateRepo.loadCheckpoint(z.checkpointId);
162
162
 
163
163
  const total = checkpoint?.totalBatches || 0;
164
164
  const completed = progress.completedBatches || 0;
165
165
  let reason = 'Unknown Stuck State';
166
- let type = 'warn'; // warn vs success
166
+ let type = 'warn';
167
167
 
168
168
  if (total > 0 && completed >= total) {
169
169
  reason = '✅ All Batches Done (Finalizer Failed)';
@@ -183,16 +183,25 @@ class TaskScheduler {
183
183
  }
184
184
  }));
185
185
 
186
- // Notify Discord with enhanced info
187
186
  if (this.notifier) {
188
187
  await this.notifier.reportZombies(recoverable.length, diagnostics);
189
188
  }
190
189
 
191
- // Claim zombies
192
190
  await Promise.all(recoverable.map(z => this.stateRepo.claimZombie(z.checkpointId)));
193
191
 
194
- // Re-dispatch logic
195
192
  const recoveryTasks = recoverable.map(z => {
193
+ // [FIX] Enforce T-1 Rule on Zombies too to avoid resurrecting future tasks
194
+ const zDate = new Date(z.date);
195
+ const now = new Date();
196
+ const maxAllowed = new Date(now);
197
+ maxAllowed.setUTCDate(now.getUTCDate() + this.MAX_DATE_OFFSET);
198
+ maxAllowed.setUTCHours(23, 59, 59, 999);
199
+
200
+ if (zDate > maxAllowed) {
201
+ this.logger.warn(`[Watchdog] Skipping recovery for ${z.name}@${z.date} (Outside Scheduling Window)`);
202
+ return null;
203
+ }
204
+
196
205
  const entry = this.manifestMap.get(z.name);
197
206
  if (!entry) return null;
198
207
 
@@ -216,8 +225,17 @@ class TaskScheduler {
216
225
  _generateDateRange(start, end) {
217
226
  const dates = [];
218
227
  let cur = new Date(start);
228
+
229
+ // [FIX] Cap the scheduling window based on config (Default T-1)
230
+ const now = new Date();
231
+ const maxAllowed = new Date(now);
232
+ maxAllowed.setUTCDate(now.getUTCDate() + this.MAX_DATE_OFFSET);
233
+ maxAllowed.setUTCHours(23, 59, 59, 999);
234
+
219
235
  while (cur <= end) {
220
- dates.push(new Date(cur));
236
+ if (cur <= maxAllowed) {
237
+ dates.push(new Date(cur));
238
+ }
221
239
  cur.setUTCDate(cur.getUTCDate() + 1);
222
240
  }
223
241
  return dates;
@@ -0,0 +1,4 @@
1
+ {
2
+ "triggered": false,
3
+ "status": "NO_DATA_FOR_DATE"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "triggered": false,
3
+ "status": "NO_TODAY_DATA"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "hasNewPost": false,
3
+ "triggered": false,
4
+ "reason": "No snapshot found for date"
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "status": "skipped",
3
+ "reason": "Missing valid snapshot pairs"
4
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "vectors": {},
3
+ "piCount": 0,
4
+ "sectorDimensions": [
5
+ "Technology",
6
+ "Healthcare",
7
+ "Financial Services",
8
+ "Consumer Cyclical",
9
+ "Industrials",
10
+ "Energy",
11
+ "Utilities",
12
+ "Real Estate",
13
+ "Communication Services",
14
+ "Consumer Defensive",
15
+ "Basic Materials",
16
+ "Crypto",
17
+ "Commodities",
18
+ "ETF",
19
+ "Other"
20
+ ],
21
+ "computedAt": "2023-01-01"
22
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "recommendations": [],
3
+ "reason": "No portfolio data"
4
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "username": null,
3
+ "socialEngagement": {
4
+ "chartType": "line",
5
+ "data": []
6
+ },
7
+ "profitablePositions": {
8
+ "chartType": "bar",
9
+ "data": []
10
+ },
11
+ "topWinningPositions": {
12
+ "chartType": "table",
13
+ "data": []
14
+ },
15
+ "sectorPerformance": {
16
+ "bestSector": null,
17
+ "worstSector": null,
18
+ "bestSectorProfit": 0,
19
+ "worstSectorProfit": 0
20
+ },
21
+ "sectorExposure": {
22
+ "chartType": "pie",
23
+ "data": {}
24
+ },
25
+ "assetExposure": {
26
+ "chartType": "pie",
27
+ "data": {}
28
+ },
29
+ "portfolioSummary": {
30
+ "totalInvested": 0,
31
+ "totalProfit": 0,
32
+ "profitPercent": 0
33
+ },
34
+ "rankingsData": {
35
+ "aum": 0,
36
+ "riskScore": 0,
37
+ "gain": 0,
38
+ "copiers": 0,
39
+ "winRatio": 0,
40
+ "trades": 0
41
+ },
42
+ "ratingsData": {
43
+ "averageRating": 0,
44
+ "totalRatings": 0,
45
+ "ratingsOverTime": {
46
+ "chartType": "line",
47
+ "data": []
48
+ }
49
+ },
50
+ "pageViewsData": {
51
+ "totalViews": 0,
52
+ "uniqueViewers": 0,
53
+ "viewsOverTime": {
54
+ "chartType": "bar",
55
+ "data": []
56
+ }
57
+ },
58
+ "watchlistData": {
59
+ "totalUsers": 0,
60
+ "watchlistOverTime": {
61
+ "chartType": "line",
62
+ "data": []
63
+ }
64
+ },
65
+ "alertHistoryData": {
66
+ "triggeredAlerts": [],
67
+ "alertCountsOverTime": {
68
+ "chartType": "bar",
69
+ "data": []
70
+ }
71
+ },
72
+ "sectorExposureOverTime": {
73
+ "chartType": "line",
74
+ "data": []
75
+ },
76
+ "assetExposureOverTime": {
77
+ "chartType": "line",
78
+ "data": []
79
+ },
80
+ "alertMetrics": {
81
+ "totalLast7Days": 0,
82
+ "breakdown": {}
83
+ }
84
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "triggered": false,
3
+ "status": "NO_TODAY_DATA"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "triggered": false,
3
+ "status": "NO_RISK_SCORE"
4
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "correlationMatrix": {},
3
+ "sectors": [],
4
+ "computedAt": "2023-01-01",
5
+ "dataPoints": 0
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "current": [],
3
+ "past": [],
4
+ "totalUniqueCopied": 0,
5
+ "updatedAt": "2026-02-07T00:33:35.007Z"
6
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "isPopularInvestor": false,
3
+ "status": "NO_PORTFOLIO_DATA"
4
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "socialEngagement": {
3
+ "chartType": "line",
4
+ "data": []
5
+ },
6
+ "myPosts": {
7
+ "chartType": "feed",
8
+ "data": []
9
+ },
10
+ "profitablePositions": {
11
+ "chartType": "bar",
12
+ "data": []
13
+ },
14
+ "topWinningPositions": {
15
+ "chartType": "table",
16
+ "data": []
17
+ },
18
+ "sectorPerformance": {
19
+ "bestSector": null,
20
+ "worstSector": null,
21
+ "bestSectorProfit": 0,
22
+ "worstSectorProfit": 0
23
+ },
24
+ "sectorExposure": {
25
+ "chartType": "pie",
26
+ "data": {}
27
+ },
28
+ "assetExposure": {
29
+ "chartType": "pie",
30
+ "data": {}
31
+ },
32
+ "portfolioSummary": {
33
+ "totalInvested": 0,
34
+ "totalProfit": 0,
35
+ "profitPercent": 0
36
+ },
37
+ "copiedPIs": {
38
+ "chartType": "cards",
39
+ "data": []
40
+ },
41
+ "recommendedPIs": {
42
+ "chartType": "cards",
43
+ "data": []
44
+ },
45
+ "recentlyViewedPages": {
46
+ "chartType": "list",
47
+ "data": []
48
+ },
49
+ "alertGraphs": {
50
+ "chartType": "line",
51
+ "data": []
52
+ },
53
+ "performanceGraphics": {
54
+ "chartType": "composite",
55
+ "data": {
56
+ "winRate": 0,
57
+ "avgWin": 0,
58
+ "avgLoss": 0,
59
+ "profitFactor": 0,
60
+ "dailyPnL": []
61
+ }
62
+ },
63
+ "exposureGraphics": {
64
+ "chartType": "composite",
65
+ "data": {}
66
+ }
67
+ }
@@ -1,33 +1,73 @@
1
1
  /**
2
- * V3 Dispatcher Handler
3
- * Entry point for triggering computations (Cloud Scheduler, PubSub, or Manual).
2
+ * V3 Dispatcher Handler (FIXED & LOGGING ENHANCED)
3
+ * Routes tasks and ensures errors are returned to Cloud Tasks logs.
4
4
  */
5
5
  exports.dispatcher = async (req, res) => {
6
+ // [DEBUG] Log the incoming trigger to match Cloud Tasks timestamps
7
+ const taskId = req.get('X-CloudTasks-TaskName') || 'unknown-task';
8
+ console.log(`[Dispatcher] Received task ${taskId} type=${req.body.type || 'unknown'}`);
9
+
6
10
  try {
7
- // LAZY LOAD to prevent circular dependency
8
11
  const system = require('../index');
9
-
10
12
  await system.initialize();
11
13
 
12
- const { computation, date, dryRun } = req.body;
14
+ const { type, computation, date, entityIds, ...params } = req.body;
13
15
 
14
- // Support V2-style 'computationName' or V3 'computation'
15
- const compName = computation || req.body.computationName;
16
- const targetDate = date || req.body.targetDate;
16
+ // 1. Route Worker Batches
17
+ if (type === 'worker-batch') {
18
+ if (!entityIds) throw new Error('Missing entityIds');
19
+ const result = await system.coordinatorInstance.processBatch({
20
+ computationName: computation, date, entityIds, ...params
21
+ });
22
+ return res.status(200).json(result);
23
+ }
24
+
25
+ // 2. Route Finalizer
26
+ if (type === 'try-finalize') {
27
+ const result = await system.coordinatorInstance.tryFinalizeComputation({
28
+ computationName: computation, date, ...params
29
+ });
30
+ return res.status(200).json(result);
31
+ }
17
32
 
18
- if (!compName) {
19
- return res.status(400).json({ error: 'Missing computation name' });
33
+ // 3. Route Cascade
34
+ if (type === 'cascade-trigger') {
35
+ const result = await system.runComputation({
36
+ computationName: computation, date, force: params.force || false
37
+ });
38
+ return res.status(200).json(result);
20
39
  }
21
40
 
41
+ // 4. Default: Coordinator (Root Triggers)
42
+ const compName = computation || req.body.computationName;
43
+ if (!compName) throw new Error('Missing computation name');
44
+
22
45
  const result = await system.runComputation({
23
46
  computationName: compName,
24
- date: targetDate || new Date().toISOString().split('T')[0],
25
- dryRun: dryRun || false
47
+ date: date || new Date().toISOString().split('T')[0],
48
+ dryRun: params.dryRun || false,
49
+ ...params
26
50
  });
27
51
 
52
+ // [LOGIC FIX] If the result is 'blocked', return 200 (OK) so Cloud Tasks STOPS retrying.
53
+ // If we return 500, Cloud Tasks will retry forever (or until max attempts).
54
+ if (result.status === 'blocked') {
55
+ console.log(`[Dispatcher] Task Blocked (Handling as Success to stop retry): ${result.reason}`);
56
+ return res.status(200).json(result);
57
+ }
58
+
28
59
  return res.status(200).json(result);
60
+
29
61
  } catch (e) {
30
- console.error('[V3-Dispatcher] Error:', e);
31
- return res.status(500).json({ error: e.message });
62
+ // [CRITICAL] Log error to Cloud Logging
63
+ console.error(`[V3-Dispatcher] CRITICAL ERROR on ${taskId}:`, e);
64
+
65
+ // Return 500 with the error message.
66
+ // Cloud Tasks 'attemptResponseLog' usually captures the first few bytes of the body.
67
+ return res.status(500).json({
68
+ error: e.message,
69
+ stack: e.stack ? e.stack.split('\n')[0] : 'no-stack',
70
+ taskId
71
+ });
32
72
  }
33
73
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.807",
3
+ "version": "1.0.809",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -57,7 +57,8 @@
57
57
  "p-limit": "^3.1.0",
58
58
  "require-all": "^3.0.0",
59
59
  "sharedsetup": "latest",
60
- "zod": "^4.3.5"
60
+ "supertest": "^7.2.2",
61
+ "zod": "^4.3.6"
61
62
  },
62
63
  "devDependencies": {
63
64
  "bulltracker-deployer": "file:../bulltracker-deployer",