bulltrackers-module 1.0.688 → 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.
@@ -3026,6 +3026,308 @@ const getWatchlistTriggerCounts = async (db, userId, watchlistId) => {
3026
3026
  }
3027
3027
  };
3028
3028
 
3029
+ /**
3030
+ * Query PIs matching dynamic watchlist criteria
3031
+ * Reads computation results and filters ALL per-user data (not just triggered CIDs)
3032
+ *
3033
+ * @param {Object} db - Firestore instance
3034
+ * @param {string} computationName - Name of the computation to query
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)
3037
+ * @param {number} limit - Maximum number of results
3038
+ * @returns {Promise<Object>} Matching PIs with their values
3039
+ */
3040
+ const queryDynamicWatchlistMatches = async (db, computationName, parameters = {}, timeRange = 'last_7_days', limit = 100) => {
3041
+ try {
3042
+ console.log(`[queryDynamicWatchlistMatches] Querying ${computationName} with params:`, parameters, `timeRange: ${timeRange}`);
3043
+
3044
+ // Determine date range based on timeRange
3045
+ const endDate = new Date();
3046
+ const startDate = new Date();
3047
+
3048
+ switch (timeRange) {
3049
+ case 'today':
3050
+ // Just today
3051
+ break;
3052
+ case 'last_7_days':
3053
+ startDate.setDate(startDate.getDate() - 7);
3054
+ break;
3055
+ case 'last_30_days':
3056
+ startDate.setDate(startDate.getDate() - 30);
3057
+ break;
3058
+ default:
3059
+ startDate.setDate(startDate.getDate() - 7);
3060
+ }
3061
+
3062
+ // Build list of dates to query (most recent first)
3063
+ const dates = [];
3064
+ for (let d = new Date(endDate); d >= startDate; d.setDate(d.getDate() - 1)) {
3065
+ dates.push(d.toISOString().split('T')[0]);
3066
+ }
3067
+
3068
+ console.log(`[queryDynamicWatchlistMatches] Checking dates:`, dates);
3069
+
3070
+ const matchingPIs = new Map(); // cid -> match data
3071
+
3072
+ // Query each date
3073
+ for (const dateStr of dates) {
3074
+ try {
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')
3081
+ .doc(dateStr)
3082
+ .collection('results')
3083
+ .doc('alerts')
3084
+ .collection('computations')
3085
+ .doc(computationName);
3086
+
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
+ }
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
+ });
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);
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
+ }
3186
+ }
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;
3194
+ }
3195
+ }
3196
+
3197
+ // Sort by match value (descending) and return
3198
+ const sortedMatches = Array.from(matchingPIs.values())
3199
+ .sort((a, b) => Math.abs(b.matchValue) - Math.abs(a.matchValue))
3200
+ .slice(0, limit);
3201
+
3202
+ console.log(`[queryDynamicWatchlistMatches] Returning ${sortedMatches.length} matches`);
3203
+
3204
+ return {
3205
+ success: true,
3206
+ matches: sortedMatches,
3207
+ count: sortedMatches.length,
3208
+ totalScanned: Object.keys(matchingPIs).length,
3209
+ dateRange: {
3210
+ start: startDate.toISOString().split('T')[0],
3211
+ end: endDate.toISOString().split('T')[0]
3212
+ },
3213
+ computationName,
3214
+ parameters
3215
+ };
3216
+ } catch (error) {
3217
+ console.error(`[queryDynamicWatchlistMatches] Error: ${error.message}`, error);
3218
+ throw error;
3219
+ }
3220
+ };
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
+
3029
3331
  /**
3030
3332
  * Subscribe to all PIs in a watchlist
3031
3333
  */
@@ -3138,5 +3440,6 @@ module.exports = {
3138
3440
  updateSubscription,
3139
3441
  unsubscribeFromAlerts,
3140
3442
  getWatchlistTriggerCounts,
3141
- subscribeToAllWatchlistPIs
3443
+ subscribeToAllWatchlistPIs,
3444
+ queryDynamicWatchlistMatches
3142
3445
  };
@@ -10,7 +10,8 @@ const {
10
10
  autoGenerateWatchlist,
11
11
  fetchPopularInvestorMasterList,
12
12
  getWatchlistTriggerCounts,
13
- subscribeToAllWatchlistPIs
13
+ subscribeToAllWatchlistPIs,
14
+ queryDynamicWatchlistMatches
14
15
  } = require('../helpers/data-fetchers/firestore.js');
15
16
  const { sanitizeDocId } = require('../helpers/security_utils.js');
16
17
 
@@ -37,6 +38,13 @@ const watchlistSubscribeSchema = z.object({
37
38
  thresholds: z.record(z.any()).optional()
38
39
  });
39
40
 
41
+ const dynamicMatchesSchema = z.object({
42
+ computationName: z.string().min(1).max(200),
43
+ parameters: z.record(z.any()).optional(), // Flexible parameters object for thresholds
44
+ timeRange: z.enum(['today', 'last_7_days', 'last_30_days']).optional(),
45
+ limit: z.number().int().min(1).max(500).optional()
46
+ });
47
+
40
48
  // GET /watchlists/all
41
49
  router.get('/all', async (req, res, next) => {
42
50
  try {
@@ -217,4 +225,82 @@ router.post('/:id/subscribe-all', async (req, res, next) => {
217
225
  }
218
226
  });
219
227
 
228
+ // POST /watchlists/dynamic/query - Query PIs matching dynamic watchlist criteria
229
+ router.post('/dynamic/query', async (req, res, next) => {
230
+ try {
231
+ // Validate input
232
+ const validatedData = dynamicMatchesSchema.parse(req.body);
233
+
234
+ const { db, logger } = req.dependencies;
235
+
236
+ logger?.log('INFO', `[POST /watchlists/dynamic/query] Querying ${validatedData.computationName} with params:`, validatedData.parameters);
237
+
238
+ const result = await queryDynamicWatchlistMatches(
239
+ db,
240
+ validatedData.computationName,
241
+ validatedData.parameters || {},
242
+ validatedData.timeRange || 'last_7_days',
243
+ validatedData.limit || 100
244
+ );
245
+
246
+ logger?.log('INFO', `[POST /watchlists/dynamic/query] Found ${result.count} matches`);
247
+
248
+ res.json(result);
249
+ } catch (error) {
250
+ if (error instanceof z.ZodError) {
251
+ return res.status(400).json({
252
+ success: false,
253
+ error: 'Invalid input',
254
+ details: error.errors
255
+ });
256
+ }
257
+ next(error);
258
+ }
259
+ });
260
+
261
+ // GET /watchlists/:id/dynamic/matches - Get current matches for a dynamic watchlist
262
+ router.get('/:id/dynamic/matches', async (req, res, next) => {
263
+ try {
264
+ const { db } = req.dependencies;
265
+ const id = sanitizeDocId(req.params.id);
266
+
267
+ // Fetch the watchlist to get its dynamic config
268
+ const watchlist = await latestUserCentricSnapshot(db, req.targetUserId, 'watchlists', 'watchlist', 'SignedInUsers', id);
269
+
270
+ if (!watchlist) {
271
+ const error = new Error("Watchlist not found");
272
+ error.status = 404;
273
+ return next(error);
274
+ }
275
+
276
+ if (watchlist.type !== 'dynamic') {
277
+ return res.status(400).json({
278
+ success: false,
279
+ error: 'This endpoint is only for dynamic watchlists'
280
+ });
281
+ }
282
+
283
+ const dynamicConfig = watchlist.dynamicConfig || {};
284
+ const result = await queryDynamicWatchlistMatches(
285
+ db,
286
+ dynamicConfig.computationName,
287
+ dynamicConfig.parameters || {},
288
+ dynamicConfig.timeRange || 'last_7_days',
289
+ 100
290
+ );
291
+
292
+ res.json({
293
+ success: true,
294
+ watchlistId: id,
295
+ watchlistName: watchlist.name,
296
+ ...result
297
+ });
298
+ } catch (error) {
299
+ if (error.message === "Watchlist not found") {
300
+ error.status = 404;
301
+ }
302
+ next(error);
303
+ }
304
+ });
305
+
220
306
  module.exports = router;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.688",
3
+ "version": "1.0.690",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [