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;
|