bulltrackers-module 1.0.689 → 1.0.690

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,16 +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)
3032
+ *
3031
3033
  * @param {Object} db - Firestore instance
3032
3034
  * @param {string} computationName - Name of the computation to query
3033
- * @param {Object} conditions - Threshold conditions to filter by
3034
- * @param {string} timeRange - Time range (today, last_7_days, last_30_days, all_time)
3035
+ * @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)
3035
3037
  * @param {number} limit - Maximum number of results
3036
3038
  * @returns {Promise<Object>} Matching PIs with their values
3037
3039
  */
3038
- const queryDynamicWatchlistMatches = async (db, computationName, conditions = {}, timeRange = 'last_7_days', limit = 100) => {
3040
+ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}, timeRange = 'last_7_days', limit = 100) => {
3039
3041
  try {
3040
- const { readComputationResultsWithShards } = require('../../alert-system/helpers/alert_helpers');
3042
+ console.log(`[queryDynamicWatchlistMatches] Querying ${computationName} with params:`, parameters, `timeRange: ${timeRange}`);
3041
3043
 
3042
3044
  // Determine date range based on timeRange
3043
3045
  const endDate = new Date();
@@ -3053,75 +3055,108 @@ const queryDynamicWatchlistMatches = async (db, computationName, conditions = {}
3053
3055
  case 'last_30_days':
3054
3056
  startDate.setDate(startDate.getDate() - 30);
3055
3057
  break;
3056
- case 'all_time':
3057
- startDate.setDate(startDate.getDate() - 365); // Max 1 year lookback
3058
- break;
3059
3058
  default:
3060
3059
  startDate.setDate(startDate.getDate() - 7);
3061
3060
  }
3062
3061
 
3063
- // Build list of dates to query
3062
+ // Build list of dates to query (most recent first)
3064
3063
  const dates = [];
3065
3064
  for (let d = new Date(endDate); d >= startDate; d.setDate(d.getDate() - 1)) {
3066
3065
  dates.push(d.toISOString().split('T')[0]);
3067
3066
  }
3068
3067
 
3069
- const matchingPIs = new Map(); // cid -> { matchValue, matchedAt, metadata }
3068
+ console.log(`[queryDynamicWatchlistMatches] Checking dates:`, dates);
3069
+
3070
+ const matchingPIs = new Map(); // cid -> match data
3070
3071
 
3071
- // Query each date until we find results
3072
+ // Query each date
3072
3073
  for (const dateStr of dates) {
3073
3074
  try {
3074
- const docRef = db.collection('unified_insights')
3075
+ // Try both 'alerts' and 'popular-investor' result paths
3076
+ let docSnapshot = null;
3077
+ let docRef = null;
3078
+
3079
+ // First try alerts path
3080
+ docRef = db.collection('unified_insights')
3075
3081
  .doc(dateStr)
3076
3082
  .collection('results')
3077
3083
  .doc('alerts')
3078
3084
  .collection('computations')
3079
3085
  .doc(computationName);
3080
3086
 
3081
- const docSnapshot = await docRef.get();
3087
+ docSnapshot = await docRef.get();
3088
+
3089
+ // If not found, try popular-investor path
3090
+ if (!docSnapshot.exists) {
3091
+ docRef = db.collection('unified_insights')
3092
+ .doc(dateStr)
3093
+ .collection('results')
3094
+ .doc('popular-investor')
3095
+ .collection('computations')
3096
+ .doc(computationName);
3097
+
3098
+ docSnapshot = await docRef.get();
3099
+ }
3100
+
3101
+ if (!docSnapshot.exists) {
3102
+ console.log(`[queryDynamicWatchlistMatches] No data for ${dateStr}`);
3103
+ continue;
3104
+ }
3082
3105
 
3083
- if (!docSnapshot.exists) continue;
3106
+ console.log(`[queryDynamicWatchlistMatches] Found data for ${dateStr}`);
3084
3107
 
3085
3108
  const docData = docSnapshot.data();
3086
- const results = await readComputationResultsWithShards(db, docData, docRef, null);
3087
3109
 
3088
- if (!results.cids || results.cids.length === 0) continue;
3110
+ // Check for sharded data
3111
+ let allUserData = {};
3089
3112
 
3090
- // Process each PI
3091
- for (const piCid of results.cids) {
3092
- // Skip if already processed
3093
- if (matchingPIs.has(piCid)) continue;
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`);
3094
3117
 
3095
- const piData = results.perUserData?.[piCid] || results.metadata || {};
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
+ });
3129
+ }
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
+ });
3140
+ }
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);
3096
3147
 
3097
- // Apply condition filtering
3098
- let passesConditions = true;
3099
- let matchValue = 0;
3148
+ // Skip if already matched from a more recent date
3149
+ if (matchingPIs.has(piCid)) continue;
3100
3150
 
3101
- // Extract match value based on computation type
3102
- if (piData.change !== undefined) {
3103
- matchValue = piData.change;
3104
- } else if (piData.current !== undefined) {
3105
- matchValue = piData.current;
3106
- } else if (piData.riskScore !== undefined) {
3107
- matchValue = piData.riskScore;
3108
- } else if (piData.volatility !== undefined) {
3109
- matchValue = piData.volatility * 100; // Convert to percentage
3110
- } else if (piData.diff !== undefined) {
3111
- matchValue = piData.diff;
3112
- }
3151
+ // Skip if error data
3152
+ if (piData.error) continue;
3113
3153
 
3114
- // Apply threshold conditions
3115
- if (conditions.minValue !== undefined && matchValue < conditions.minValue) {
3116
- passesConditions = false;
3117
- }
3118
- if (conditions.maxValue !== undefined && matchValue > conditions.maxValue) {
3119
- passesConditions = false;
3120
- }
3154
+ // Apply computation-specific filtering based on parameters
3155
+ const passesFilter = checkPIMatchesCriteria(computationName, piData, parameters);
3121
3156
 
3122
- if (passesConditions) {
3157
+ if (passesFilter.passes) {
3123
3158
  // Get PI username from master list
3124
- let username = piData.piUsername || piData.username || `PI-${piCid}`;
3159
+ let username = `PI-${piCid}`;
3125
3160
  try {
3126
3161
  const piProfile = await fetchPopularInvestorMasterList(db, String(piCid));
3127
3162
  if (piProfile && piProfile.username) {
@@ -3132,14 +3167,13 @@ const queryDynamicWatchlistMatches = async (db, computationName, conditions = {}
3132
3167
  }
3133
3168
 
3134
3169
  matchingPIs.set(piCid, {
3135
- cid: Number(piCid),
3170
+ cid: piCid,
3136
3171
  username,
3137
3172
  matchedAt: dateStr,
3138
- matchValue,
3139
- previousValue: piData.previous || piData.prev,
3140
- trend: piData.previous !== undefined
3141
- ? (matchValue > piData.previous ? 'up' : matchValue < piData.previous ? 'down' : 'stable')
3142
- : undefined,
3173
+ matchValue: passesFilter.matchValue,
3174
+ currentValue: passesFilter.currentValue,
3175
+ previousValue: passesFilter.previousValue,
3176
+ change: passesFilter.change,
3143
3177
  metadata: {
3144
3178
  ...piData,
3145
3179
  computationDate: dateStr
@@ -3151,11 +3185,11 @@ const queryDynamicWatchlistMatches = async (db, computationName, conditions = {}
3151
3185
  }
3152
3186
  }
3153
3187
 
3154
- // Break out of date loop if we have enough results
3188
+ // If we found enough matches, stop looking at older dates
3155
3189
  if (matchingPIs.size >= limit) break;
3156
3190
 
3157
3191
  } catch (dateErr) {
3158
- console.warn(`Error processing date ${dateStr} for dynamic watchlist: ${dateErr.message}`);
3192
+ console.warn(`[queryDynamicWatchlistMatches] Error processing date ${dateStr}: ${dateErr.message}`);
3159
3193
  continue;
3160
3194
  }
3161
3195
  }
@@ -3165,22 +3199,135 @@ const queryDynamicWatchlistMatches = async (db, computationName, conditions = {}
3165
3199
  .sort((a, b) => Math.abs(b.matchValue) - Math.abs(a.matchValue))
3166
3200
  .slice(0, limit);
3167
3201
 
3202
+ console.log(`[queryDynamicWatchlistMatches] Returning ${sortedMatches.length} matches`);
3203
+
3168
3204
  return {
3169
3205
  success: true,
3170
3206
  matches: sortedMatches,
3171
3207
  count: sortedMatches.length,
3208
+ totalScanned: Object.keys(matchingPIs).length,
3172
3209
  dateRange: {
3173
3210
  start: startDate.toISOString().split('T')[0],
3174
3211
  end: endDate.toISOString().split('T')[0]
3175
3212
  },
3176
- computationName
3213
+ computationName,
3214
+ parameters
3177
3215
  };
3178
3216
  } catch (error) {
3179
- console.error(`Error querying dynamic watchlist matches: ${error}`);
3217
+ console.error(`[queryDynamicWatchlistMatches] Error: ${error.message}`, error);
3180
3218
  throw error;
3181
3219
  }
3182
3220
  };
3183
3221
 
3222
+ /**
3223
+ * Check if a PI's data matches the watchlist criteria
3224
+ * Handles different computation types with their specific data formats
3225
+ */
3226
+ function checkPIMatchesCriteria(computationName, piData, parameters) {
3227
+ const result = { passes: false, matchValue: 0, currentValue: null, previousValue: null, change: null };
3228
+
3229
+ switch (computationName) {
3230
+ case 'RiskScoreIncrease': {
3231
+ // Data format: { change, currentRisk, previousRisk, isBaselineReset }
3232
+ const change = piData.change || 0;
3233
+ const currentRisk = piData.currentRisk;
3234
+ const previousRisk = piData.previousRisk;
3235
+ const minChange = parameters.minChange ?? 0;
3236
+ const minRiskLevel = parameters.minRiskLevel ?? 0;
3237
+
3238
+ result.currentValue = currentRisk;
3239
+ result.previousValue = previousRisk;
3240
+ result.change = change;
3241
+ result.matchValue = currentRisk; // Sort by current risk level
3242
+
3243
+ // Pass if change >= minChange AND currentRisk >= minRiskLevel
3244
+ if (change >= minChange && currentRisk >= minRiskLevel) {
3245
+ result.passes = true;
3246
+ }
3247
+ break;
3248
+ }
3249
+
3250
+ case 'SignificantVolatility': {
3251
+ // Data format: { volatility, threshold, samples }
3252
+ const volatility = piData.volatility || 0;
3253
+ const minVolatility = parameters.volatilityThreshold ?? 50;
3254
+
3255
+ result.currentValue = volatility;
3256
+ result.matchValue = volatility;
3257
+
3258
+ if (volatility >= minVolatility) {
3259
+ result.passes = true;
3260
+ }
3261
+ break;
3262
+ }
3263
+
3264
+ case 'PositionInvestedIncrease': {
3265
+ // Data format: { moveCount, moves: [{symbol, prev, curr, diff}], isBaselineReset }
3266
+ const moveCount = piData.moveCount || 0;
3267
+ const moves = piData.moves || [];
3268
+ const minIncrease = parameters.minIncrease ?? 5;
3269
+
3270
+ // Find the largest position increase
3271
+ const maxMove = moves.reduce((max, m) => m.diff > max ? m.diff : max, 0);
3272
+
3273
+ result.currentValue = moveCount;
3274
+ result.matchValue = maxMove;
3275
+
3276
+ if (maxMove >= minIncrease) {
3277
+ result.passes = true;
3278
+ }
3279
+ break;
3280
+ }
3281
+
3282
+ case 'NewSectorExposure': {
3283
+ // Data format: { currentSectors, previousSectors, newExposures, isBaselineReset }
3284
+ const newExposures = piData.newExposures || [];
3285
+
3286
+ result.currentValue = newExposures.length;
3287
+ result.matchValue = newExposures.length;
3288
+
3289
+ if (newExposures.length > 0) {
3290
+ result.passes = true;
3291
+ }
3292
+ break;
3293
+ }
3294
+
3295
+ case 'NewSocialPost': {
3296
+ // Data format: { hasNewPost, latestPostDate, postCount, title }
3297
+ const hasNewPost = piData.hasNewPost || false;
3298
+
3299
+ result.currentValue = hasNewPost ? 1 : 0;
3300
+ result.matchValue = hasNewPost ? 1 : 0;
3301
+
3302
+ if (hasNewPost) {
3303
+ result.passes = true;
3304
+ }
3305
+ break;
3306
+ }
3307
+
3308
+ case 'BehavioralAnomaly': {
3309
+ // Data format: { anomalyScore, primaryDriver, driverSignificance }
3310
+ const anomalyScore = piData.anomalyScore || 0;
3311
+ const minScore = parameters.minAnomalyScore ?? 0;
3312
+
3313
+ result.currentValue = anomalyScore;
3314
+ result.matchValue = anomalyScore;
3315
+
3316
+ if (anomalyScore >= minScore) {
3317
+ result.passes = true;
3318
+ }
3319
+ break;
3320
+ }
3321
+
3322
+ default:
3323
+ // Generic fallback - pass if there's any data
3324
+ result.passes = true;
3325
+ result.matchValue = 0;
3326
+ }
3327
+
3328
+ return result;
3329
+ }
3330
+
3184
3331
  /**
3185
3332
  * Subscribe to all PIs in a watchlist
3186
3333
  */
@@ -40,11 +40,8 @@ const watchlistSubscribeSchema = z.object({
40
40
 
41
41
  const dynamicMatchesSchema = z.object({
42
42
  computationName: z.string().min(1).max(200),
43
- conditions: z.object({
44
- minValue: z.number().optional(),
45
- maxValue: z.number().optional()
46
- }).optional(),
47
- timeRange: z.enum(['today', 'last_7_days', 'last_30_days', 'all_time']).optional(),
43
+ parameters: z.record(z.any()).optional(), // Flexible parameters object for thresholds
44
+ timeRange: z.enum(['today', 'last_7_days', 'last_30_days']).optional(),
48
45
  limit: z.number().int().min(1).max(500).optional()
49
46
  });
50
47
 
@@ -234,15 +231,20 @@ router.post('/dynamic/query', async (req, res, next) => {
234
231
  // Validate input
235
232
  const validatedData = dynamicMatchesSchema.parse(req.body);
236
233
 
237
- const { db } = req.dependencies;
234
+ const { db, logger } = req.dependencies;
235
+
236
+ logger?.log('INFO', `[POST /watchlists/dynamic/query] Querying ${validatedData.computationName} with params:`, validatedData.parameters);
237
+
238
238
  const result = await queryDynamicWatchlistMatches(
239
239
  db,
240
240
  validatedData.computationName,
241
- validatedData.conditions || {},
241
+ validatedData.parameters || {},
242
242
  validatedData.timeRange || 'last_7_days',
243
243
  validatedData.limit || 100
244
244
  );
245
245
 
246
+ logger?.log('INFO', `[POST /watchlists/dynamic/query] Found ${result.count} matches`);
247
+
246
248
  res.json(result);
247
249
  } catch (error) {
248
250
  if (error instanceof z.ZodError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.689",
3
+ "version": "1.0.690",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [