bulltrackers-module 1.0.692 → 1.0.694

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.
@@ -3028,12 +3028,18 @@ const getWatchlistTriggerCounts = async (db, userId, watchlistId) => {
3028
3028
 
3029
3029
  /**
3030
3030
  * Query PIs matching dynamic watchlist criteria
3031
- * Reads computation results and filters ALL per-user data (not just triggered CIDs)
3031
+ *
3032
+ * IMPORTANT: This function only evaluates PIs based on the MOST RECENT available data.
3033
+ * The timeRange parameter controls how far back we look to FIND data (in case today's
3034
+ * computation hasn't run yet), but we DO NOT aggregate matches across multiple dates.
3035
+ *
3036
+ * If a PI matched criteria 3 days ago but doesn't match on the most recent data,
3037
+ * they will NOT be included - they "dropped off" the watchlist.
3032
3038
  *
3033
3039
  * @param {Object} db - Firestore instance
3034
3040
  * @param {string} computationName - Name of the computation to query
3035
3041
  * @param {Object} parameters - Threshold parameters from watchlist config (e.g., {minChange: 1, minRiskLevel: 5})
3036
- * @param {string} timeRange - Time range (today, last_7_days, last_30_days)
3042
+ * @param {string} timeRange - Time range to look back for data availability (today, last_7_days, last_30_days)
3037
3043
  * @param {number} limit - Maximum number of results
3038
3044
  * @returns {Promise<Object>} Matching PIs with their values
3039
3045
  */
@@ -3041,7 +3047,7 @@ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}
3041
3047
  try {
3042
3048
  console.log(`[queryDynamicWatchlistMatches] Querying ${computationName} with params:`, parameters, `timeRange: ${timeRange}`);
3043
3049
 
3044
- // Determine date range based on timeRange
3050
+ // Determine how far back to look for data availability
3045
3051
  const endDate = new Date();
3046
3052
  const startDate = new Date();
3047
3053
 
@@ -3059,24 +3065,22 @@ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}
3059
3065
  startDate.setDate(startDate.getDate() - 7);
3060
3066
  }
3061
3067
 
3062
- // Build list of dates to query (most recent first)
3068
+ // Build list of dates to check (most recent first)
3063
3069
  const dates = [];
3064
3070
  for (let d = new Date(endDate); d >= startDate; d.setDate(d.getDate() - 1)) {
3065
3071
  dates.push(d.toISOString().split('T')[0]);
3066
3072
  }
3067
3073
 
3068
- console.log(`[queryDynamicWatchlistMatches] Checking dates:`, dates);
3074
+ console.log(`[queryDynamicWatchlistMatches] Looking for most recent data in date range:`, dates);
3069
3075
 
3070
- const matchingPIs = new Map(); // cid -> match data
3076
+ // Find the MOST RECENT date that has computation data
3077
+ let mostRecentDate = null;
3078
+ let docRef = null;
3079
+ let docSnapshot = null;
3071
3080
 
3072
- // Query each date
3073
3081
  for (const dateStr of dates) {
3074
3082
  try {
3075
- // Try both 'alerts' and 'popular-investor' result paths
3076
- let docSnapshot = null;
3077
- let docRef = null;
3078
-
3079
- // First try alerts path
3083
+ // Try alerts path first
3080
3084
  docRef = db.collection('unified_insights')
3081
3085
  .doc(dateStr)
3082
3086
  .collection('results')
@@ -3098,118 +3102,134 @@ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}
3098
3102
  docSnapshot = await docRef.get();
3099
3103
  }
3100
3104
 
3101
- if (!docSnapshot.exists) {
3102
- console.log(`[queryDynamicWatchlistMatches] No data for ${dateStr}`);
3103
- continue;
3105
+ if (docSnapshot.exists) {
3106
+ mostRecentDate = dateStr;
3107
+ console.log(`[queryDynamicWatchlistMatches] Found most recent data on ${dateStr}`);
3108
+ break; // Stop searching - we found the most recent data
3104
3109
  }
3105
-
3106
- console.log(`[queryDynamicWatchlistMatches] Found data for ${dateStr}`);
3107
-
3108
- const docData = docSnapshot.data();
3109
-
3110
- // Check for sharded data
3111
- let allUserData = {};
3112
-
3113
- if (docData._sharded === true) {
3114
- // Read from shards
3115
- const shardsSnapshot = await docRef.collection('_shards').get();
3116
- console.log(`[queryDynamicWatchlistMatches] Found ${shardsSnapshot.size} shards`);
3117
-
3118
- for (const shardDoc of shardsSnapshot.docs) {
3119
- const shardData = shardDoc.data();
3120
- // Each shard contains CID keys with their data
3121
- Object.entries(shardData).forEach(([key, value]) => {
3122
- // Skip metadata keys
3123
- if (key.startsWith('_') || key === 'cids' || key === 'metadata') return;
3124
- // Check if key is a CID (numeric string)
3125
- if (/^\d+$/.test(key)) {
3126
- allUserData[key] = value;
3127
- }
3128
- });
3110
+ } catch (dateErr) {
3111
+ console.warn(`[queryDynamicWatchlistMatches] Error checking date ${dateStr}: ${dateErr.message}`);
3112
+ continue;
3113
+ }
3114
+ }
3115
+
3116
+ // If no data found in the entire range, return empty
3117
+ if (!mostRecentDate || !docSnapshot || !docSnapshot.exists) {
3118
+ console.log(`[queryDynamicWatchlistMatches] No computation data found for ${computationName} in date range`);
3119
+ return {
3120
+ success: true,
3121
+ matches: [],
3122
+ count: 0,
3123
+ totalScanned: 0,
3124
+ dateRange: {
3125
+ start: startDate.toISOString().split('T')[0],
3126
+ end: endDate.toISOString().split('T')[0]
3127
+ },
3128
+ dataDate: null,
3129
+ computationName,
3130
+ parameters,
3131
+ message: `No computation data found for ${computationName} in the selected time range`
3132
+ };
3133
+ }
3134
+
3135
+ const docData = docSnapshot.data();
3136
+
3137
+ // Read all per-user data from the most recent date
3138
+ let allUserData = {};
3139
+
3140
+ if (docData._sharded === true) {
3141
+ // Read from shards
3142
+ const shardsSnapshot = await docRef.collection('_shards').get();
3143
+ console.log(`[queryDynamicWatchlistMatches] Found ${shardsSnapshot.size} shards for ${mostRecentDate}`);
3144
+
3145
+ for (const shardDoc of shardsSnapshot.docs) {
3146
+ const shardData = shardDoc.data();
3147
+ Object.entries(shardData).forEach(([key, value]) => {
3148
+ // Skip metadata keys, only include CID keys
3149
+ if (key.startsWith('_') || key === 'cids' || key === 'metadata') return;
3150
+ if (/^\d+$/.test(key)) {
3151
+ allUserData[key] = value;
3129
3152
  }
3130
- } else {
3131
- // Data is in the document itself
3132
- Object.entries(docData).forEach(([key, value]) => {
3133
- // Skip metadata keys
3134
- if (key.startsWith('_') || key === 'cids' || key === 'metadata' || key === 'globalMetadata') return;
3135
- // Check if key is a CID (numeric string)
3136
- if (/^\d+$/.test(key)) {
3137
- allUserData[key] = value;
3138
- }
3139
- });
3153
+ });
3154
+ }
3155
+ } else {
3156
+ // Data is in the document itself
3157
+ Object.entries(docData).forEach(([key, value]) => {
3158
+ if (key.startsWith('_') || key === 'cids' || key === 'metadata' || key === 'globalMetadata') return;
3159
+ if (/^\d+$/.test(key)) {
3160
+ allUserData[key] = value;
3140
3161
  }
3141
-
3142
- console.log(`[queryDynamicWatchlistMatches] Found ${Object.keys(allUserData).length} PIs in computation data`);
3143
-
3144
- // Process ALL PIs (not just those in cids array)
3145
- for (const [piCidStr, piData] of Object.entries(allUserData)) {
3146
- const piCid = Number(piCidStr);
3147
-
3148
- // Skip if already matched from a more recent date
3149
- if (matchingPIs.has(piCid)) continue;
3150
-
3151
- // Skip if error data
3152
- if (piData.error) continue;
3153
-
3154
- // Apply computation-specific filtering based on parameters
3155
- const passesFilter = checkPIMatchesCriteria(computationName, piData, parameters);
3156
-
3157
- if (passesFilter.passes) {
3158
- // Get PI username from master list
3159
- let username = `PI-${piCid}`;
3160
- try {
3161
- const piProfile = await fetchPopularInvestorMasterList(db, String(piCid));
3162
- if (piProfile && piProfile.username) {
3163
- username = piProfile.username;
3164
- }
3165
- } catch (e) {
3166
- // Use default username
3167
- }
3168
-
3169
- matchingPIs.set(piCid, {
3170
- cid: piCid,
3171
- username,
3172
- matchedAt: dateStr,
3173
- matchValue: passesFilter.matchValue,
3174
- currentValue: passesFilter.currentValue,
3175
- previousValue: passesFilter.previousValue,
3176
- change: passesFilter.change,
3177
- metadata: {
3178
- ...piData,
3179
- computationDate: dateStr
3180
- }
3181
- });
3182
-
3183
- // Stop if we've hit the limit
3184
- if (matchingPIs.size >= limit) break;
3185
- }
3162
+ });
3163
+ }
3164
+
3165
+ const totalPIs = Object.keys(allUserData).length;
3166
+ console.log(`[queryDynamicWatchlistMatches] Evaluating ${totalPIs} PIs from ${mostRecentDate} against criteria`);
3167
+
3168
+ // Filter PIs that match the criteria on this most recent date
3169
+ const matchingPIs = [];
3170
+ const cidsToLookup = [];
3171
+
3172
+ for (const [piCidStr, piData] of Object.entries(allUserData)) {
3173
+ const piCid = Number(piCidStr);
3174
+
3175
+ // Skip error data
3176
+ if (piData.error) continue;
3177
+
3178
+ // Check if PI matches the criteria
3179
+ const filterResult = checkPIMatchesCriteria(computationName, piData, parameters);
3180
+
3181
+ if (filterResult.passes) {
3182
+ cidsToLookup.push({ piCid, piData, filterResult });
3183
+ }
3184
+ }
3185
+
3186
+ console.log(`[queryDynamicWatchlistMatches] ${cidsToLookup.length} PIs match criteria, fetching usernames...`);
3187
+
3188
+ // Fetch usernames for matching PIs (batch for efficiency)
3189
+ for (const { piCid, piData, filterResult } of cidsToLookup.slice(0, limit)) {
3190
+ let username = `PI-${piCid}`;
3191
+ try {
3192
+ const piProfile = await fetchPopularInvestorMasterList(db, String(piCid));
3193
+ if (piProfile && piProfile.username) {
3194
+ username = piProfile.username;
3186
3195
  }
3187
-
3188
- // If we found enough matches, stop looking at older dates
3189
- if (matchingPIs.size >= limit) break;
3190
-
3191
- } catch (dateErr) {
3192
- console.warn(`[queryDynamicWatchlistMatches] Error processing date ${dateStr}: ${dateErr.message}`);
3193
- continue;
3196
+ } catch (e) {
3197
+ // Use default username
3194
3198
  }
3199
+
3200
+ matchingPIs.push({
3201
+ cid: piCid,
3202
+ username,
3203
+ matchedAt: mostRecentDate,
3204
+ matchValue: filterResult.matchValue,
3205
+ currentValue: filterResult.currentValue,
3206
+ previousValue: filterResult.previousValue,
3207
+ change: filterResult.change,
3208
+ metadata: {
3209
+ ...piData,
3210
+ computationDate: mostRecentDate
3211
+ }
3212
+ });
3195
3213
  }
3196
3214
 
3197
- // Sort by match value (descending) and return
3198
- const sortedMatches = Array.from(matchingPIs.values())
3215
+ // Sort by match value (descending)
3216
+ const sortedMatches = matchingPIs
3199
3217
  .sort((a, b) => Math.abs(b.matchValue) - Math.abs(a.matchValue))
3200
3218
  .slice(0, limit);
3201
3219
 
3202
- console.log(`[queryDynamicWatchlistMatches] Returning ${sortedMatches.length} matches`);
3220
+ console.log(`[queryDynamicWatchlistMatches] Returning ${sortedMatches.length} matches from ${mostRecentDate}`);
3203
3221
 
3204
3222
  return {
3205
3223
  success: true,
3206
3224
  matches: sortedMatches,
3207
3225
  count: sortedMatches.length,
3208
- totalScanned: Object.keys(matchingPIs).length,
3226
+ totalScanned: totalPIs,
3227
+ totalMatching: cidsToLookup.length,
3209
3228
  dateRange: {
3210
3229
  start: startDate.toISOString().split('T')[0],
3211
3230
  end: endDate.toISOString().split('T')[0]
3212
3231
  },
3232
+ dataDate: mostRecentDate, // The actual date the data is from
3213
3233
  computationName,
3214
3234
  parameters
3215
3235
  };
@@ -420,7 +420,8 @@ async function writeSingleResult(result, docRef, name, dateContext, category, lo
420
420
  }
421
421
 
422
422
  const rootUpdate = updates.find(u => u.ref.path === docRef.path && u.type !== 'DELETE');
423
- if (rootUpdate) { rootUpdate.options = { merge: rootMergeOption }; }
423
+ // FIX: Always use merge: false to ensure old fields (like _compressed/payload) are wiped
424
+ if (rootUpdate) { rootUpdate.options = { merge: false }; }
424
425
 
425
426
  const writes = updates.filter(u => u.type !== 'DELETE').length;
426
427
  const deletes = updates.filter(u => u.type === 'DELETE').length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.692",
3
+ "version": "1.0.694",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -1,172 +0,0 @@
1
- /**
2
- * file: alert-system/helpers/alert_type_registry.js
3
- */
4
- const ALERT_TYPES = {
5
- increasedRisk: {
6
- id: 'increasedRisk',
7
- name: 'Increased Risk',
8
- description: 'Alert when a Popular Investor\'s risk score increases',
9
- computationName: 'RiskScoreIncrease',
10
- category: 'alerts',
11
- messageTemplate: '{piUsername}\'s risk score increased by {change} points (from {previous} to {current})',
12
- severity: 'high',
13
- enabled: true
14
- },
15
- volatilityChanges: {
16
- id: 'volatilityChanges',
17
- name: 'Significant Volatility',
18
- description: 'Alert when a Popular Investor\'s portfolio volatility exceeds 50%',
19
- computationName: 'SignificantVolatility',
20
- category: 'alerts',
21
- messageTemplate: '{piUsername}\'s portfolio volatility is {volatility}% (threshold: {threshold}%)',
22
- severity: 'medium',
23
- enabled: true
24
- },
25
- newSector: {
26
- id: 'newSector',
27
- name: 'New Sector Entry',
28
- description: 'Alert when a Popular Investor enters a new sector',
29
- computationName: 'NewSectorExposure',
30
- category: 'alerts',
31
- messageTemplate: '{piUsername} entered new sector(s): {sectorName}',
32
- severity: 'low',
33
- enabled: true
34
- },
35
- increasedPositionSize: {
36
- id: 'increasedPositionSize',
37
- name: 'Increased Position Size',
38
- description: 'Alert when a Popular Investor significantly increases a position size (>5%)',
39
- computationName: 'PositionInvestedIncrease',
40
- category: 'alerts',
41
- messageTemplate: '{piUsername} increased position size for {symbol} by {diff}% (from {prev}% to {curr}%)',
42
- severity: 'medium',
43
- enabled: true
44
- },
45
- newSocialPost: {
46
- id: 'newSocialPost',
47
- name: 'New Social Post',
48
- description: 'Alert when a Popular Investor makes a new social post',
49
- computationName: 'NewSocialPost',
50
- category: 'alerts',
51
- messageTemplate: '{piUsername} posted a new update: {title}',
52
- severity: 'low',
53
- enabled: true
54
- },
55
- // [NEW] Behavioral Anomaly Registration
56
- behavioralAnomaly: {
57
- id: 'behavioralAnomaly',
58
- name: 'Behavioral Anomaly',
59
- description: 'Alert when a Popular Investor deviates significantly from their baseline behavior',
60
- computationName: 'BehavioralAnomaly',
61
- category: 'alerts',
62
- // Uses metadata keys from Behaviour.js: primaryDriver, driverSignificance, anomalyScore
63
- messageTemplate: 'Behavioral Alert for {piUsername}: {primaryDriver} Deviation ({driverSignificance}) detected.',
64
- severity: 'high',
65
- enabled: true
66
- },
67
- testSystemProbe: {
68
- id: 'testSystemProbe',
69
- name: 'Test System Probe',
70
- description: 'Always-on debug alert',
71
- computationName: 'TestSystemProbe',
72
- category: 'alerts',
73
- messageTemplate: 'Probe Triggered for {status} at {timestamp}',
74
- severity: 'info',
75
- enabled: true
76
- },
77
- };
78
-
79
- /**
80
- * Get alert type by ID
81
- */
82
- function getAlertType(alertTypeId) {
83
- return ALERT_TYPES[alertTypeId] || null;
84
- }
85
-
86
- /**
87
- * Get alert type by computation name
88
- */
89
- function getAlertTypeByComputation(computationName) {
90
- for (const [id, alertType] of Object.entries(ALERT_TYPES)) {
91
- if (alertType.computationName === computationName) {
92
- return alertType;
93
- }
94
- }
95
- return null;
96
- }
97
-
98
- /**
99
- * Get all enabled alert types
100
- */
101
- function getAllAlertTypes() {
102
- return Object.values(ALERT_TYPES).filter(type => type.enabled);
103
- }
104
-
105
- /**
106
- * Check if a computation is an alert computation
107
- */
108
- function isAlertComputation(computationName) {
109
- return getAlertTypeByComputation(computationName) !== null;
110
- }
111
-
112
- /**
113
- * Generate alert message from template and metadata
114
- */
115
- function generateAlertMessage(alertType, piUsername, metadata = {}) {
116
- let message = alertType.messageTemplate;
117
-
118
- // Replace placeholders
119
- message = message.replace('{piUsername}', piUsername || 'Unknown');
120
- message = message.replace('{count}', metadata.count || metadata.positions?.length || metadata.moveCount || 0);
121
- message = message.replace('{change}', metadata.change || metadata.changePercent || metadata.diff || 'N/A');
122
- message = message.replace('{previous}', metadata.previous || metadata.previousValue || metadata.prev || metadata.previousRisk || 'N/A');
123
- message = message.replace('{current}', metadata.current || metadata.currentValue || metadata.curr || metadata.currentRisk || 'N/A');
124
- message = message.replace('{sectorName}', metadata.sectorName || (metadata.newExposures && metadata.newExposures.length > 0 ? metadata.newExposures.join(', ') : 'Unknown'));
125
- message = message.replace('{ticker}', metadata.ticker || metadata.symbol || 'Unknown');
126
-
127
- // Format numeric values
128
- message = message.replace('{volatility}', metadata.volatility ? `${(metadata.volatility * 100).toFixed(1)}` : 'N/A');
129
- message = message.replace('{threshold}', metadata.threshold ? `${(metadata.threshold * 100).toFixed(0)}` : 'N/A');
130
- message = message.replace('{diff}', metadata.diff ? `${metadata.diff.toFixed(1)}` : 'N/A');
131
- message = message.replace('{prev}', metadata.prev ? `${metadata.prev.toFixed(1)}` : 'N/A');
132
- message = message.replace('{curr}', metadata.curr ? `${metadata.curr.toFixed(1)}` : 'N/A');
133
- message = message.replace('{title}', metadata.title || 'New Update');
134
-
135
- // [FIX] Probe Placeholders (Missing in original)
136
- message = message.replace('{status}', metadata.status || 'Unknown Status');
137
- message = message.replace('{timestamp}', metadata.timestamp || new Date().toISOString());
138
-
139
- // [NEW] Behavioral Anomaly Placeholders
140
- message = message.replace('{primaryDriver}', metadata.primaryDriver || 'Unknown Factor');
141
- message = message.replace('{driverSignificance}', metadata.driverSignificance || 'N/A');
142
- message = message.replace('{anomalyScore}', metadata.anomalyScore || 'N/A');
143
-
144
- // Handle positions list if available
145
- if (metadata.positions && Array.isArray(metadata.positions) && metadata.positions.length > 0) {
146
- const positionsList = metadata.positions
147
- .slice(0, 3)
148
- .map(p => p.ticker || p.instrumentId)
149
- .join(', ');
150
- message = message.replace('{positions}', positionsList);
151
- }
152
-
153
- // Handle moves array for PositionInvestedIncrease
154
- if (metadata.moves && Array.isArray(metadata.moves) && metadata.moves.length > 0) {
155
- const firstMove = metadata.moves[0];
156
- message = message.replace('{symbol}', firstMove.symbol || 'Unknown');
157
- message = message.replace('{diff}', firstMove.diff ? `${firstMove.diff.toFixed(1)}` : 'N/A');
158
- message = message.replace('{prev}', firstMove.prev ? `${firstMove.prev.toFixed(1)}` : 'N/A');
159
- message = message.replace('{curr}', firstMove.curr ? `${firstMove.curr.toFixed(1)}` : 'N/A');
160
- }
161
-
162
- return message;
163
- }
164
-
165
- module.exports = {
166
- ALERT_TYPES,
167
- getAlertType,
168
- getAlertTypeByComputation,
169
- getAllAlertTypes,
170
- isAlertComputation,
171
- generateAlertMessage
172
- };