aiden-shared-calculations-unified 1.0.34 → 1.0.36

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 (58) hide show
  1. package/README.MD +77 -77
  2. package/calculations/activity/historical/activity_by_pnl_status.js +85 -85
  3. package/calculations/activity/historical/daily_asset_activity.js +85 -85
  4. package/calculations/activity/historical/daily_user_activity_tracker.js +144 -144
  5. package/calculations/activity/historical/speculator_adjustment_activity.js +76 -76
  6. package/calculations/asset_metrics/asset_position_size.js +57 -57
  7. package/calculations/backtests/strategy-performance.js +229 -245
  8. package/calculations/behavioural/historical/asset_crowd_flow.js +165 -170
  9. package/calculations/behavioural/historical/drawdown_response.js +58 -58
  10. package/calculations/behavioural/historical/dumb-cohort-flow.js +249 -249
  11. package/calculations/behavioural/historical/gain_response.js +57 -57
  12. package/calculations/behavioural/historical/in_loss_asset_crowd_flow.js +98 -98
  13. package/calculations/behavioural/historical/in_profit_asset_crowd_flow.js +99 -99
  14. package/calculations/behavioural/historical/paper_vs_diamond_hands.js +39 -39
  15. package/calculations/behavioural/historical/position_count_pnl.js +67 -67
  16. package/calculations/behavioural/historical/smart-cohort-flow.js +250 -250
  17. package/calculations/behavioural/historical/smart_money_flow.js +165 -165
  18. package/calculations/behavioural/historical/user-investment-profile.js +412 -412
  19. package/calculations/capital_flow/historical/crowd-cash-flow-proxy.js +121 -121
  20. package/calculations/capital_flow/historical/deposit_withdrawal_percentage.js +117 -117
  21. package/calculations/capital_flow/historical/new_allocation_percentage.js +49 -49
  22. package/calculations/insights/daily_bought_vs_sold_count.js +55 -55
  23. package/calculations/insights/daily_buy_sell_sentiment_count.js +49 -49
  24. package/calculations/insights/daily_ownership_delta.js +55 -55
  25. package/calculations/insights/daily_total_positions_held.js +39 -39
  26. package/calculations/meta/capital_deployment_strategy.js +129 -137
  27. package/calculations/meta/capital_liquidation_performance.js +121 -163
  28. package/calculations/meta/capital_vintage_performance.js +121 -158
  29. package/calculations/meta/cash-flow-deployment.js +110 -124
  30. package/calculations/meta/cash-flow-liquidation.js +126 -142
  31. package/calculations/meta/crowd_sharpe_ratio_proxy.js +83 -91
  32. package/calculations/meta/profit_cohort_divergence.js +77 -91
  33. package/calculations/meta/smart-dumb-divergence-index.js +116 -138
  34. package/calculations/meta/social_flow_correlation.js +99 -125
  35. package/calculations/pnl/asset_pnl_status.js +46 -46
  36. package/calculations/pnl/historical/profitability_migration.js +57 -57
  37. package/calculations/pnl/historical/user_profitability_tracker.js +117 -117
  38. package/calculations/pnl/profitable_and_unprofitable_status.js +64 -64
  39. package/calculations/sectors/historical/diversification_pnl.js +76 -76
  40. package/calculations/sectors/historical/sector_rotation.js +67 -67
  41. package/calculations/sentiment/historical/crowd_conviction_score.js +80 -80
  42. package/calculations/socialPosts/social-asset-posts-trend.js +52 -52
  43. package/calculations/socialPosts/social-top-mentioned-words.js +102 -102
  44. package/calculations/socialPosts/social-topic-interest-evolution.js +53 -53
  45. package/calculations/socialPosts/social-word-mentions-trend.js +62 -62
  46. package/calculations/socialPosts/social_activity_aggregation.js +103 -103
  47. package/calculations/socialPosts/social_event_correlation.js +121 -121
  48. package/calculations/socialPosts/social_sentiment_aggregation.js +114 -114
  49. package/calculations/speculators/historical/risk_appetite_change.js +54 -54
  50. package/calculations/speculators/historical/tsl_effectiveness.js +74 -74
  51. package/index.js +33 -33
  52. package/package.json +32 -32
  53. package/utils/firestore_utils.js +76 -76
  54. package/utils/price_data_provider.js +142 -142
  55. package/utils/sector_mapping_provider.js +74 -74
  56. package/calculations/capital_flow/historical/reallocation_increase_percentage.js +0 -63
  57. package/calculations/speculators/stop_loss_distance_by_sector_short_long_breakdown.js +0 -91
  58. package/calculations/speculators/stop_loss_distance_by_ticker_short_long_breakdown.js +0 -73
@@ -1,413 +1,413 @@
1
- /**
2
- * @fileoverview Calculates a rolling 90-day "Investor Score" (IS) for each normal user.
3
- * Heuristic engine (not an academic finance model). Outputs:
4
- * - sharded_user_profile: { <shardKey>: { profiles: { userId: [history...] }, lastUpdated } }
5
- * - daily_investor_scores: { userId: finalIS }
6
- *
7
- * Notes:
8
- * - NetProfit / ProfitAndLoss fields are assumed to be percent returns in decimal (e.g. 0.03 = +3%).
9
- * - The "Sharpe" used here is a cross-sectional dispersion proxy computed over position returns,
10
- * weighted by invested amounts. It's renamed/treated as a dispersionRiskProxy in comments.
11
- */
12
-
13
- const { Firestore } = require('@google-cloud/firestore');
14
- const firestore = new Firestore();
15
- const { loadAllPriceData } = require('../../../utils/price_data_provider');
16
- const { getInstrumentSectorMap, loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
17
-
18
- // Config
19
- const NUM_SHARDS = 50; // Must match the number of shards to read/write
20
- const ROLLING_DAYS = 90;
21
- const SHARD_COLLECTION_NAME = 'user_profile_history'; // The collection to store sharded history
22
- const PNL_TRACKER_CALC_ID = 'user-profitability-tracker'; // The calc to read PNL from
23
-
24
- // Helper: stable shard index for numeric or string IDs
25
- function getShardIndex(id) {
26
- const n = parseInt(id, 10);
27
- if (!Number.isNaN(n)) return Math.abs(n) % NUM_SHARDS;
28
- // simple deterministic string hash fallback for non-numeric IDs (UUIDs)
29
- let h = 0;
30
- for (let i = 0; i < id.length; i++) {
31
- h = ((h << 5) - h) + id.charCodeAt(i);
32
- h |= 0; // keep 32-bit
33
- }
34
- return Math.abs(h) % NUM_SHARDS;
35
- }
36
-
37
- class UserInvestmentProfile {
38
- constructor() {
39
- // will hold today's per-user raw heuristic scores
40
- this.dailyUserScores = {}; // { userId: { score_rd, score_disc, score_time } }
41
-
42
- // cached dependencies
43
- this.priceMap = null;
44
- this.sectorMap = null;
45
- this.pnlScores = null; // { userId: dailyPnlDecimal }
46
- this.dates = {};
47
- this.dependenciesLoaded = false;
48
-
49
- // --- START MODIFICATION ---
50
- // Flag to track if dependencies loaded successfully
51
- this.dependencyLoadedSuccess = false;
52
- // --- END MODIFICATION ---
53
- }
54
-
55
- /**
56
- * Loads external dependencies once per run.
57
- */
58
- async _loadDependencies(context, dependencies) {
59
- if (this.dependenciesLoaded) return;
60
-
61
- const { db, logger } = dependencies;
62
- const { todayDateStr } = context;
63
-
64
- if (logger) logger.log('INFO', '[UserInvestmentProfile] Loading dependencies...');
65
-
66
- // load price data and sector mapping in parallel
67
- const [priceData, sectorData] = await Promise.all([
68
- loadAllPriceData(),
69
- getInstrumentSectorMap()
70
- ]);
71
- this.priceMap = priceData || {};
72
- this.sectorMap = sectorData || {};
73
-
74
- // load PNL map (daily percent returns per user) from PNL calc
75
- this.pnlScores = {};
76
- try {
77
- const pnlCalcRef = db.collection(context.config.resultsCollection).doc(todayDateStr)
78
- .collection(context.config.resultsSubcollection).doc('pnl')
79
- .collection(context.config.computationsSubcollection).doc(PNL_TRACKER_CALC_ID);
80
-
81
- const pnlSnap = await pnlCalcRef.get();
82
-
83
- // --- START MODIFICATION ---
84
- // Check for existence of the doc AND the data within it
85
- if (pnlSnap.exists && pnlSnap.data().daily_pnl_map) {
86
- this.pnlScores = pnlSnap.data().daily_pnl_map || {};
87
- if (logger) logger.log('INFO', `[UserInvestmentProfile] Loaded ${Object.keys(this.pnlScores).length} PNL scores.`);
88
- this.dependencyLoadedSuccess = true; // Set success flag
89
- } else {
90
- if (logger) logger.log('WARN', `[UserInvestmentProfile] Could not find PNL scores dependency for ${todayDateStr}. PNL score will be 0. Aborting profile calculation.`);
91
- this.dependencyLoadedSuccess = false; // Set failure flag
92
- }
93
- // --- END MODIFICATION ---
94
-
95
- } catch (e) {
96
- if (logger) logger.log('ERROR', `[UserInvestmentProfile] Failed to load PNL scores.`, { error: e.message });
97
- this.dependencyLoadedSuccess = false; // Set failure flag on error
98
- }
99
-
100
- this.dependenciesLoaded = true;
101
- if (logger) logger.log('INFO', '[UserInvestmentProfile] All dependencies loaded.');
102
- }
103
-
104
- // ... [Heuristic calculation functions _calculateRiskAndDivScore, _calculateDisciplineScore, _calculateMarketTimingScore are unchanged] ...
105
-
106
- /**
107
- * HEURISTIC 1: Risk & Diversification Score (0-10).
108
- */
109
- _calculateRiskAndDivScore(todayPortfolio) {
110
- if (!todayPortfolio.AggregatedPositions || todayPortfolio.AggregatedPositions.length === 0) {
111
- return 5; // neutral
112
- }
113
-
114
- const positions = todayPortfolio.AggregatedPositions;
115
- let totalInvested = 0;
116
- let weightedRetSum = 0;
117
- let weightedRetSqSum = 0;
118
- let maxPosition = 0;
119
- const sectors = new Set();
120
-
121
- for (const pos of positions) {
122
- const invested = pos.InvestedAmount || pos.Amount || 0;
123
- const netProfit = ('NetProfit' in pos) ? pos.NetProfit : (pos.ProfitAndLoss || 0); // decimal % return
124
- const ret = invested > 0 ? (netProfit) : netProfit; // if invested==0 we still include ret but weight 0
125
-
126
- weightedRetSum += ret * invested;
127
- weightedRetSqSum += (ret * ret) * invested;
128
- totalInvested += invested;
129
- if (invested > maxPosition) maxPosition = invested;
130
-
131
- sectors.add(this.sectorMap[pos.InstrumentID] || 'N/A');
132
- }
133
-
134
- // Weighted mean & variance of returns
135
- const meanReturn = totalInvested > 0 ? (weightedRetSum / totalInvested) : 0;
136
- const meanReturnSq = totalInvested > 0 ? (weightedRetSqSum / totalInvested) : (meanReturn * meanReturn);
137
- const variance = Math.max(0, meanReturnSq - (meanReturn * meanReturn));
138
- const stdReturn = Math.sqrt(variance);
139
-
140
- // dispersion proxy: mean / std (if std is zero we treat as neutral 0)
141
- let dispersionRiskProxy = stdReturn > 0 ? meanReturn / stdReturn : 0;
142
-
143
- // cap and map dispersion proxy to [0..10].
144
- // dispersionRiskProxy can be outside [-2..4], clamp to reasonable bounds first.
145
- const capped = Math.max(-2, Math.min(4, dispersionRiskProxy));
146
- const scoreSharpe = ((capped + 2) / 6) * 10; // maps [-2..4] -> [0..10]
147
-
148
- // Sector diversification (monotonic - diminishing returns)
149
- const sectorCount = sectors.size;
150
- let scoreDiversification = 0;
151
- if (sectorCount === 1) scoreDiversification = 0;
152
- else if (sectorCount <= 4) scoreDiversification = 5;
153
- else if (sectorCount <= 7) scoreDiversification = 8;
154
- else scoreDiversification = 10;
155
-
156
- // Position sizing / concentration penalty
157
- const concentrationRatio = totalInvested > 0 ? (maxPosition / totalInvested) : 0;
158
- let scoreSizing = 0;
159
- if (concentrationRatio > 0.8) scoreSizing = 0;
160
- else if (concentrationRatio > 0.5) scoreSizing = 2;
161
- else if (concentrationRatio > 0.3) scoreSizing = 5;
162
- else if (concentrationRatio > 0.15) scoreSizing = 8;
163
- else scoreSizing = 10;
164
-
165
- const final = (scoreSharpe * 0.4) + (scoreDiversification * 0.3) + (scoreSizing * 0.3);
166
- return Math.max(0, Math.min(10, final));
167
- }
168
-
169
- /**
170
- * HEURISTIC 2: Discipline Score (0-10).
171
- */
172
- _calculateDisciplineScore(yesterdayPortfolio = {}, todayPortfolio = {}) {
173
- const yPositions = yesterdayPortfolio.AggregatedPositions || [];
174
- const tPositions = new Map((todayPortfolio.AggregatedPositions || []).map(p => [p.PositionID, p]));
175
-
176
- if (yPositions.length === 0) {
177
- return 5; // neutral if nothing to judge
178
- }
179
-
180
- let eventPoints = 0;
181
- let eventCount = 0;
182
-
183
- for (const yPos of yPositions) {
184
- const profitAndLoss = ('ProfitAndLoss' in yPos) ? yPos.ProfitAndLoss : (yPos.NetProfit || 0);
185
- const invested = yPos.InvestedAmount || yPos.Amount || 0;
186
- const pnlPercent = profitAndLoss; // This is already the decimal % return
187
-
188
- const tPos = tPositions.get(yPos.PositionID);
189
-
190
- if (!tPos) {
191
- // Closed position
192
- eventCount++;
193
- if (pnlPercent < -0.05) eventPoints += 10; // cut loser (good)
194
- else if (pnlPercent > 0.20) eventPoints += 8; // took big profit (good)
195
- else if (pnlPercent > 0 && pnlPercent < 0.05) eventPoints += 2; // paper hands (bad)
196
- else eventPoints += 5; // neutral close
197
- } else {
198
- // Held or modified
199
- if (pnlPercent < -0.10) {
200
- eventCount++;
201
- const tInvested = tPos.InvestedAmount || tPos.Amount || 0;
202
- if (tInvested > invested) eventPoints += 0; // averaged down (very poor)
203
- else eventPoints += 3; // held loser (poor)
204
- } else if (pnlPercent > 0.15) {
205
- eventCount++;
206
- eventPoints += 10; // held/added to winner (good)
207
- }
208
- }
209
- }
210
-
211
- const avg = (eventCount > 0) ? (eventPoints / eventCount) : 5;
212
- return Math.max(0, Math.min(10, avg));
213
- }
214
-
215
- /**
216
- * HEURISTIC 3: Market Timing Score (0-10).
217
- */
218
- _calculateMarketTimingScore(yesterdayPortfolio = {}, todayPortfolio = {}) {
219
- const yIds = new Set((yesterdayPortfolio.AggregatedPositions || []).map(p => p.PositionID));
220
- const newPositions = (todayPortfolio.AggregatedPositions || []).filter(p => !yIds.has(p.PositionID));
221
-
222
- if (newPositions.length === 0) return 5;
223
-
224
- let timingPoints = 0;
225
- let timingCount = 0;
226
-
227
- for (const tPos of newPositions) {
228
- const prices = this.priceMap[tPos.InstrumentID];
229
- if (!prices) continue;
230
-
231
- // Accept prices as either array or {date:price} map; build sorted array of prices
232
- let historyPrices = [];
233
- if (Array.isArray(prices)) {
234
- // assume array of numbers or objects with .price/.close
235
- historyPrices = prices
236
- .map(p => (typeof p === 'number' ? p : (p.price || p.close || null)))
237
- .filter(v => v != null);
238
- } else {
239
- // object keyed by date -> price
240
- const entries = Object.keys(prices)
241
- .map(d => ({ d, p: prices[d] }))
242
- .filter(e => e.p != null)
243
- .sort((a, b) => new Date(a.d) - new Date(b.d));
244
- historyPrices = entries.map(e => e.p);
245
- }
246
-
247
- const last30 = historyPrices.slice(-30);
248
- if (last30.length < 2) continue;
249
-
250
- const minPrice = Math.min(...last30);
251
- const maxPrice = Math.max(...last30);
252
- const openRate = tPos.OpenRate;
253
- const range = maxPrice - minPrice;
254
- if (!isFinite(range) || range === 0) continue;
255
-
256
- let proximity = (openRate - minPrice) / range; // 0 = at low, 1 = at high
257
- proximity = Math.max(0, Math.min(1, proximity)); // clamp to [0,1]
258
-
259
- timingCount++;
260
- if (proximity < 0.2) timingPoints += 10;
261
- else if (proximity < 0.4) timingPoints += 8;
262
- else if (proximity > 0.9) timingPoints += 1;
263
- else if (proximity > 0.7) timingPoints += 3;
264
- else timingPoints += 5;
265
- }
266
-
267
- const avg = (timingCount > 0) ? (timingPoints / timingCount) : 5;
268
- return Math.max(0, Math.min(10, avg));
269
- }
270
-
271
- /**
272
- * PROCESS: called per-user per-day to compute and store today's heuristics.
273
- */
274
- async process(todayPortfolio, yesterdayPortfolio, userId, context, todayInsights, yesterdayInsights, todaySocial, yesterdaySocial) {
275
- // run only for normal users with portfolios
276
- if (!todayPortfolio || !todayPortfolio.AggregatedPositions) return;
277
-
278
- if (!this.dependenciesLoaded) {
279
- await this._loadDependencies(context, context.dependencies);
280
- this.dates.today = context.todayDateStr;
281
- }
282
-
283
- // --- START MODIFICATION ---
284
- // If dependencies failed to load (e.g., PNL doc was missing), stop processing.
285
- if (!this.dependencyLoadedSuccess) {
286
- return;
287
- }
288
- // --- END MODIFICATION ---
289
-
290
- const yPort = yesterdayPortfolio || {};
291
-
292
- const score_rd = this._calculateRiskAndDivScore(todayPortfolio);
293
- const score_disc = this._calculateDisciplineScore(yPort, todayPortfolio);
294
- const score_time = this._calculateMarketTimingScore(yPort, todayPortfolio);
295
-
296
- this.dailyUserScores[userId] = {
297
- score_rd,
298
- score_disc,
299
- score_time
300
- };
301
- }
302
-
303
- /**
304
- * GETRESULT: Aggregate into rolling 90-day history, compute avg components and final IS.
305
- */
306
- async getResult() {
307
- // --- START MODIFICATION ---
308
- // If dependencies failed, return null to trigger backfill.
309
- if (!this.dependencyLoadedSuccess) {
310
- // Logger might not be available here, use console.warn
311
- console.warn('[UserInvestmentProfile] Skipping getResult as dependency (pnl-tracker) failed.');
312
- return null;
313
- }
314
-
315
- // If no users were processed (e.g., all were filtered out), return null.
316
- if (Object.keys(this.dailyUserScores).length === 0) {
317
- console.warn('[UserInvestmentProfile] No daily user scores were calculated. Returning null for backfill.');
318
- return null;
319
- }
320
- // --- END MODIFICATION ---
321
-
322
- const todayStr = this.dates.today || (new Date()).toISOString().slice(0, 10);
323
-
324
- // prepare sharded output objects with profiles container (Option A)
325
- const shardedResults = {};
326
- for (let i = 0; i < NUM_SHARDS; i++) {
327
- const shardKey = `${SHARD_COLLECTION_NAME}_shard_${i}`;
328
- shardedResults[shardKey] = { profiles: {}, lastUpdated: todayStr };
329
- }
330
-
331
- const dailyInvestorScoreMap = {};
332
-
333
- // fetch existing shards in parallel
334
- const shardPromises = [];
335
- for (let i = 0; i < NUM_SHARDS; i++) {
336
- const docRef = firestore.collection(SHARD_COLLECTION_NAME).doc(`${SHARD_COLLECTION_NAME}_shard_${i}`);
337
- shardPromises.push(docRef.get());
338
- }
339
- const shardSnapshots = await Promise.all(shardPromises);
340
-
341
- // Build existingShards map of profiles for quick access
342
- const existingShards = shardSnapshots.map((snap, idx) => {
343
- if (!snap.exists) return {}; // no profiles
344
- const data = snap.data() || {};
345
- return data.profiles || {};
346
- });
347
-
348
- // process users
349
- for (const userId of Object.keys(this.dailyUserScores)) {
350
- const shardIndex = getShardIndex(userId);
351
- const scores = this.dailyUserScores[userId];
352
-
353
- // fetch existing history for this user (if present)
354
- const existingProfiles = existingShards[shardIndex] || {};
355
- // clone to avoid mutating snapshot data directly
356
- const history = (existingProfiles[userId] || []).slice();
357
-
358
- history.push({
359
- date: todayStr,
360
- ...scores,
361
- pnl: (this.pnlScores && (userId in this.pnlScores)) ? this.pnlScores[userId] : 0
362
- });
363
-
364
- const newHistory = history.slice(-ROLLING_DAYS);
365
-
366
- // compute rolling averages
367
- let avg_rd = 0, avg_disc = 0, avg_time = 0, avg_pnl = 0;
368
- for (const entry of newHistory) {
369
- avg_rd += (entry.score_rd || 0);
370
- avg_disc += (entry.score_disc || 0);
371
- avg_time += (entry.score_time || 0);
372
- avg_pnl += (entry.pnl || 0);
373
- }
374
- const N = newHistory.length || 1;
375
- avg_rd /= N;
376
- avg_disc /= N;
377
- avg_time /= N;
378
- avg_pnl /= N;
379
-
380
- // Normalize PNL: avg_pnl is decimal percent (0.005 -> 0.5%). Map to 0-10 scale:
381
- // multiply by 1000 (0.005 -> 5). Clamp to [-10, 10] to avoid outliers.
382
- const normalizedPnl = Math.max(-10, Math.min(10, avg_pnl * 1000));
383
-
384
- // Final IS (weights): discipline 40%, risk/div 30%, timing 20%, pnl 10%
385
- const finalISRaw = (avg_disc * 0.4) + (avg_rd * 0.3) + (avg_time * 0.2) + (normalizedPnl * 0.1);
386
- const finalIS = Math.max(0, Math.min(10, finalISRaw));
387
-
388
- // store in prepared shard result under 'profiles'
389
- const shardKey = `${SHARD_COLLECTION_NAME}_shard_${shardIndex}`;
390
- shardedResults[shardKey].profiles[userId] = newHistory;
391
-
392
- // also set the daily investor score
393
- dailyInvestorScoreMap[userId] = finalIS;
394
- }
395
-
396
- return {
397
- sharded_user_profile: shardedResults,
398
- daily_investor_scores: dailyInvestorScoreMap
399
- };
400
- }
401
-
402
- reset() {
403
- this.dailyUserScores = {};
404
- this.dependenciesLoaded = false;
405
- this.priceMap = null;
406
- this.sectorMap = null;
407
- this.pnlScores = null;
408
- this.dates = {};
409
- this.dependencyLoadedSuccess = false; // <-- MODIFICATION
410
- }
411
- }
412
-
1
+ /**
2
+ * @fileoverview Calculates a rolling 90-day "Investor Score" (IS) for each normal user.
3
+ * Heuristic engine (not an academic finance model). Outputs:
4
+ * - sharded_user_profile: { <shardKey>: { profiles: { userId: [history...] }, lastUpdated } }
5
+ * - daily_investor_scores: { userId: finalIS }
6
+ *
7
+ * Notes:
8
+ * - NetProfit / ProfitAndLoss fields are assumed to be percent returns in decimal (e.g. 0.03 = +3%).
9
+ * - The "Sharpe" used here is a cross-sectional dispersion proxy computed over position returns,
10
+ * weighted by invested amounts. It's renamed/treated as a dispersionRiskProxy in comments.
11
+ */
12
+
13
+ const { Firestore } = require('@google-cloud/firestore');
14
+ const firestore = new Firestore();
15
+ const { loadAllPriceData } = require('../../../utils/price_data_provider');
16
+ const { getInstrumentSectorMap, loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
17
+
18
+ // Config
19
+ const NUM_SHARDS = 50; // Must match the number of shards to read/write
20
+ const ROLLING_DAYS = 90;
21
+ const SHARD_COLLECTION_NAME = 'user_profile_history'; // The collection to store sharded history
22
+ const PNL_TRACKER_CALC_ID = 'user-profitability-tracker'; // The calc to read PNL from
23
+
24
+ // Helper: stable shard index for numeric or string IDs
25
+ function getShardIndex(id) {
26
+ const n = parseInt(id, 10);
27
+ if (!Number.isNaN(n)) return Math.abs(n) % NUM_SHARDS;
28
+ // simple deterministic string hash fallback for non-numeric IDs (UUIDs)
29
+ let h = 0;
30
+ for (let i = 0; i < id.length; i++) {
31
+ h = ((h << 5) - h) + id.charCodeAt(i);
32
+ h |= 0; // keep 32-bit
33
+ }
34
+ return Math.abs(h) % NUM_SHARDS;
35
+ }
36
+
37
+ class UserInvestmentProfile {
38
+ constructor() {
39
+ // will hold today's per-user raw heuristic scores
40
+ this.dailyUserScores = {}; // { userId: { score_rd, score_disc, score_time } }
41
+
42
+ // cached dependencies
43
+ this.priceMap = null;
44
+ this.sectorMap = null;
45
+ this.pnlScores = null; // { userId: dailyPnlDecimal }
46
+ this.dates = {};
47
+ this.dependenciesLoaded = false;
48
+
49
+ // --- START MODIFICATION ---
50
+ // Flag to track if dependencies loaded successfully
51
+ this.dependencyLoadedSuccess = false;
52
+ // --- END MODIFICATION ---
53
+ }
54
+
55
+ /**
56
+ * Loads external dependencies once per run.
57
+ */
58
+ async _loadDependencies(context, dependencies) {
59
+ if (this.dependenciesLoaded) return;
60
+
61
+ const { db, logger } = dependencies;
62
+ const { todayDateStr } = context;
63
+
64
+ if (logger) logger.log('INFO', '[UserInvestmentProfile] Loading dependencies...');
65
+
66
+ // load price data and sector mapping in parallel
67
+ const [priceData, sectorData] = await Promise.all([
68
+ loadAllPriceData(),
69
+ getInstrumentSectorMap()
70
+ ]);
71
+ this.priceMap = priceData || {};
72
+ this.sectorMap = sectorData || {};
73
+
74
+ // load PNL map (daily percent returns per user) from PNL calc
75
+ this.pnlScores = {};
76
+ try {
77
+ const pnlCalcRef = db.collection(context.config.resultsCollection).doc(todayDateStr)
78
+ .collection(context.config.resultsSubcollection).doc('pnl')
79
+ .collection(context.config.computationsSubcollection).doc(PNL_TRACKER_CALC_ID);
80
+
81
+ const pnlSnap = await pnlCalcRef.get();
82
+
83
+ // --- START MODIFICATION ---
84
+ // Check for existence of the doc AND the data within it
85
+ if (pnlSnap.exists && pnlSnap.data().daily_pnl_map) {
86
+ this.pnlScores = pnlSnap.data().daily_pnl_map || {};
87
+ if (logger) logger.log('INFO', `[UserInvestmentProfile] Loaded ${Object.keys(this.pnlScores).length} PNL scores.`);
88
+ this.dependencyLoadedSuccess = true; // Set success flag
89
+ } else {
90
+ if (logger) logger.log('WARN', `[UserInvestmentProfile] Could not find PNL scores dependency for ${todayDateStr}. PNL score will be 0. Aborting profile calculation.`);
91
+ this.dependencyLoadedSuccess = false; // Set failure flag
92
+ }
93
+ // --- END MODIFICATION ---
94
+
95
+ } catch (e) {
96
+ if (logger) logger.log('ERROR', `[UserInvestmentProfile] Failed to load PNL scores.`, { error: e.message });
97
+ this.dependencyLoadedSuccess = false; // Set failure flag on error
98
+ }
99
+
100
+ this.dependenciesLoaded = true;
101
+ if (logger) logger.log('INFO', '[UserInvestmentProfile] All dependencies loaded.');
102
+ }
103
+
104
+ // ... [Heuristic calculation functions _calculateRiskAndDivScore, _calculateDisciplineScore, _calculateMarketTimingScore are unchanged] ...
105
+
106
+ /**
107
+ * HEURISTIC 1: Risk & Diversification Score (0-10).
108
+ */
109
+ _calculateRiskAndDivScore(todayPortfolio) {
110
+ if (!todayPortfolio.AggregatedPositions || todayPortfolio.AggregatedPositions.length === 0) {
111
+ return 5; // neutral
112
+ }
113
+
114
+ const positions = todayPortfolio.AggregatedPositions;
115
+ let totalInvested = 0;
116
+ let weightedRetSum = 0;
117
+ let weightedRetSqSum = 0;
118
+ let maxPosition = 0;
119
+ const sectors = new Set();
120
+
121
+ for (const pos of positions) {
122
+ const invested = pos.InvestedAmount || pos.Amount || 0;
123
+ const netProfit = ('NetProfit' in pos) ? pos.NetProfit : (pos.ProfitAndLoss || 0); // decimal % return
124
+ const ret = invested > 0 ? (netProfit) : netProfit; // if invested==0 we still include ret but weight 0
125
+
126
+ weightedRetSum += ret * invested;
127
+ weightedRetSqSum += (ret * ret) * invested;
128
+ totalInvested += invested;
129
+ if (invested > maxPosition) maxPosition = invested;
130
+
131
+ sectors.add(this.sectorMap[pos.InstrumentID] || 'N/A');
132
+ }
133
+
134
+ // Weighted mean & variance of returns
135
+ const meanReturn = totalInvested > 0 ? (weightedRetSum / totalInvested) : 0;
136
+ const meanReturnSq = totalInvested > 0 ? (weightedRetSqSum / totalInvested) : (meanReturn * meanReturn);
137
+ const variance = Math.max(0, meanReturnSq - (meanReturn * meanReturn));
138
+ const stdReturn = Math.sqrt(variance);
139
+
140
+ // dispersion proxy: mean / std (if std is zero we treat as neutral 0)
141
+ let dispersionRiskProxy = stdReturn > 0 ? meanReturn / stdReturn : 0;
142
+
143
+ // cap and map dispersion proxy to [0..10].
144
+ // dispersionRiskProxy can be outside [-2..4], clamp to reasonable bounds first.
145
+ const capped = Math.max(-2, Math.min(4, dispersionRiskProxy));
146
+ const scoreSharpe = ((capped + 2) / 6) * 10; // maps [-2..4] -> [0..10]
147
+
148
+ // Sector diversification (monotonic - diminishing returns)
149
+ const sectorCount = sectors.size;
150
+ let scoreDiversification = 0;
151
+ if (sectorCount === 1) scoreDiversification = 0;
152
+ else if (sectorCount <= 4) scoreDiversification = 5;
153
+ else if (sectorCount <= 7) scoreDiversification = 8;
154
+ else scoreDiversification = 10;
155
+
156
+ // Position sizing / concentration penalty
157
+ const concentrationRatio = totalInvested > 0 ? (maxPosition / totalInvested) : 0;
158
+ let scoreSizing = 0;
159
+ if (concentrationRatio > 0.8) scoreSizing = 0;
160
+ else if (concentrationRatio > 0.5) scoreSizing = 2;
161
+ else if (concentrationRatio > 0.3) scoreSizing = 5;
162
+ else if (concentrationRatio > 0.15) scoreSizing = 8;
163
+ else scoreSizing = 10;
164
+
165
+ const final = (scoreSharpe * 0.4) + (scoreDiversification * 0.3) + (scoreSizing * 0.3);
166
+ return Math.max(0, Math.min(10, final));
167
+ }
168
+
169
+ /**
170
+ * HEURISTIC 2: Discipline Score (0-10).
171
+ */
172
+ _calculateDisciplineScore(yesterdayPortfolio = {}, todayPortfolio = {}) {
173
+ const yPositions = yesterdayPortfolio.AggregatedPositions || [];
174
+ const tPositions = new Map((todayPortfolio.AggregatedPositions || []).map(p => [p.PositionID, p]));
175
+
176
+ if (yPositions.length === 0) {
177
+ return 5; // neutral if nothing to judge
178
+ }
179
+
180
+ let eventPoints = 0;
181
+ let eventCount = 0;
182
+
183
+ for (const yPos of yPositions) {
184
+ const profitAndLoss = ('ProfitAndLoss' in yPos) ? yPos.ProfitAndLoss : (yPos.NetProfit || 0);
185
+ const invested = yPos.InvestedAmount || yPos.Amount || 0;
186
+ const pnlPercent = profitAndLoss; // This is already the decimal % return
187
+
188
+ const tPos = tPositions.get(yPos.PositionID);
189
+
190
+ if (!tPos) {
191
+ // Closed position
192
+ eventCount++;
193
+ if (pnlPercent < -0.05) eventPoints += 10; // cut loser (good)
194
+ else if (pnlPercent > 0.20) eventPoints += 8; // took big profit (good)
195
+ else if (pnlPercent > 0 && pnlPercent < 0.05) eventPoints += 2; // paper hands (bad)
196
+ else eventPoints += 5; // neutral close
197
+ } else {
198
+ // Held or modified
199
+ if (pnlPercent < -0.10) {
200
+ eventCount++;
201
+ const tInvested = tPos.InvestedAmount || tPos.Amount || 0;
202
+ if (tInvested > invested) eventPoints += 0; // averaged down (very poor)
203
+ else eventPoints += 3; // held loser (poor)
204
+ } else if (pnlPercent > 0.15) {
205
+ eventCount++;
206
+ eventPoints += 10; // held/added to winner (good)
207
+ }
208
+ }
209
+ }
210
+
211
+ const avg = (eventCount > 0) ? (eventPoints / eventCount) : 5;
212
+ return Math.max(0, Math.min(10, avg));
213
+ }
214
+
215
+ /**
216
+ * HEURISTIC 3: Market Timing Score (0-10).
217
+ */
218
+ _calculateMarketTimingScore(yesterdayPortfolio = {}, todayPortfolio = {}) {
219
+ const yIds = new Set((yesterdayPortfolio.AggregatedPositions || []).map(p => p.PositionID));
220
+ const newPositions = (todayPortfolio.AggregatedPositions || []).filter(p => !yIds.has(p.PositionID));
221
+
222
+ if (newPositions.length === 0) return 5;
223
+
224
+ let timingPoints = 0;
225
+ let timingCount = 0;
226
+
227
+ for (const tPos of newPositions) {
228
+ const prices = this.priceMap[tPos.InstrumentID];
229
+ if (!prices) continue;
230
+
231
+ // Accept prices as either array or {date:price} map; build sorted array of prices
232
+ let historyPrices = [];
233
+ if (Array.isArray(prices)) {
234
+ // assume array of numbers or objects with .price/.close
235
+ historyPrices = prices
236
+ .map(p => (typeof p === 'number' ? p : (p.price || p.close || null)))
237
+ .filter(v => v != null);
238
+ } else {
239
+ // object keyed by date -> price
240
+ const entries = Object.keys(prices)
241
+ .map(d => ({ d, p: prices[d] }))
242
+ .filter(e => e.p != null)
243
+ .sort((a, b) => new Date(a.d) - new Date(b.d));
244
+ historyPrices = entries.map(e => e.p);
245
+ }
246
+
247
+ const last30 = historyPrices.slice(-30);
248
+ if (last30.length < 2) continue;
249
+
250
+ const minPrice = Math.min(...last30);
251
+ const maxPrice = Math.max(...last30);
252
+ const openRate = tPos.OpenRate;
253
+ const range = maxPrice - minPrice;
254
+ if (!isFinite(range) || range === 0) continue;
255
+
256
+ let proximity = (openRate - minPrice) / range; // 0 = at low, 1 = at high
257
+ proximity = Math.max(0, Math.min(1, proximity)); // clamp to [0,1]
258
+
259
+ timingCount++;
260
+ if (proximity < 0.2) timingPoints += 10;
261
+ else if (proximity < 0.4) timingPoints += 8;
262
+ else if (proximity > 0.9) timingPoints += 1;
263
+ else if (proximity > 0.7) timingPoints += 3;
264
+ else timingPoints += 5;
265
+ }
266
+
267
+ const avg = (timingCount > 0) ? (timingPoints / timingCount) : 5;
268
+ return Math.max(0, Math.min(10, avg));
269
+ }
270
+
271
+ /**
272
+ * PROCESS: called per-user per-day to compute and store today's heuristics.
273
+ */
274
+ async process(todayPortfolio, yesterdayPortfolio, userId, context, todayInsights, yesterdayInsights, todaySocial, yesterdaySocial) {
275
+ // run only for normal users with portfolios
276
+ if (!todayPortfolio || !todayPortfolio.AggregatedPositions) return;
277
+
278
+ if (!this.dependenciesLoaded) {
279
+ await this._loadDependencies(context, context.dependencies);
280
+ this.dates.today = context.todayDateStr;
281
+ }
282
+
283
+ // --- START MODIFICATION ---
284
+ // If dependencies failed to load (e.g., PNL doc was missing), stop processing.
285
+ if (!this.dependencyLoadedSuccess) {
286
+ return;
287
+ }
288
+ // --- END MODIFICATION ---
289
+
290
+ const yPort = yesterdayPortfolio || {};
291
+
292
+ const score_rd = this._calculateRiskAndDivScore(todayPortfolio);
293
+ const score_disc = this._calculateDisciplineScore(yPort, todayPortfolio);
294
+ const score_time = this._calculateMarketTimingScore(yPort, todayPortfolio);
295
+
296
+ this.dailyUserScores[userId] = {
297
+ score_rd,
298
+ score_disc,
299
+ score_time
300
+ };
301
+ }
302
+
303
+ /**
304
+ * GETRESULT: Aggregate into rolling 90-day history, compute avg components and final IS.
305
+ */
306
+ async getResult() {
307
+ // --- START MODIFICATION ---
308
+ // If dependencies failed, return null to trigger backfill.
309
+ if (!this.dependencyLoadedSuccess) {
310
+ // Logger might not be available here, use console.warn
311
+ console.warn('[UserInvestmentProfile] Skipping getResult as dependency (pnl-tracker) failed.');
312
+ return null;
313
+ }
314
+
315
+ // If no users were processed (e.g., all were filtered out), return null.
316
+ if (Object.keys(this.dailyUserScores).length === 0) {
317
+ console.warn('[UserInvestmentProfile] No daily user scores were calculated. Returning null for backfill.');
318
+ return null;
319
+ }
320
+ // --- END MODIFICATION ---
321
+
322
+ const todayStr = this.dates.today || (new Date()).toISOString().slice(0, 10);
323
+
324
+ // prepare sharded output objects with profiles container (Option A)
325
+ const shardedResults = {};
326
+ for (let i = 0; i < NUM_SHARDS; i++) {
327
+ const shardKey = `${SHARD_COLLECTION_NAME}_shard_${i}`;
328
+ shardedResults[shardKey] = { profiles: {}, lastUpdated: todayStr };
329
+ }
330
+
331
+ const dailyInvestorScoreMap = {};
332
+
333
+ // fetch existing shards in parallel
334
+ const shardPromises = [];
335
+ for (let i = 0; i < NUM_SHARDS; i++) {
336
+ const docRef = firestore.collection(SHARD_COLLECTION_NAME).doc(`${SHARD_COLLECTION_NAME}_shard_${i}`);
337
+ shardPromises.push(docRef.get());
338
+ }
339
+ const shardSnapshots = await Promise.all(shardPromises);
340
+
341
+ // Build existingShards map of profiles for quick access
342
+ const existingShards = shardSnapshots.map((snap, idx) => {
343
+ if (!snap.exists) return {}; // no profiles
344
+ const data = snap.data() || {};
345
+ return data.profiles || {};
346
+ });
347
+
348
+ // process users
349
+ for (const userId of Object.keys(this.dailyUserScores)) {
350
+ const shardIndex = getShardIndex(userId);
351
+ const scores = this.dailyUserScores[userId];
352
+
353
+ // fetch existing history for this user (if present)
354
+ const existingProfiles = existingShards[shardIndex] || {};
355
+ // clone to avoid mutating snapshot data directly
356
+ const history = (existingProfiles[userId] || []).slice();
357
+
358
+ history.push({
359
+ date: todayStr,
360
+ ...scores,
361
+ pnl: (this.pnlScores && (userId in this.pnlScores)) ? this.pnlScores[userId] : 0
362
+ });
363
+
364
+ const newHistory = history.slice(-ROLLING_DAYS);
365
+
366
+ // compute rolling averages
367
+ let avg_rd = 0, avg_disc = 0, avg_time = 0, avg_pnl = 0;
368
+ for (const entry of newHistory) {
369
+ avg_rd += (entry.score_rd || 0);
370
+ avg_disc += (entry.score_disc || 0);
371
+ avg_time += (entry.score_time || 0);
372
+ avg_pnl += (entry.pnl || 0);
373
+ }
374
+ const N = newHistory.length || 1;
375
+ avg_rd /= N;
376
+ avg_disc /= N;
377
+ avg_time /= N;
378
+ avg_pnl /= N;
379
+
380
+ // Normalize PNL: avg_pnl is decimal percent (0.005 -> 0.5%). Map to 0-10 scale:
381
+ // multiply by 1000 (0.005 -> 5). Clamp to [-10, 10] to avoid outliers.
382
+ const normalizedPnl = Math.max(-10, Math.min(10, avg_pnl * 1000));
383
+
384
+ // Final IS (weights): discipline 40%, risk/div 30%, timing 20%, pnl 10%
385
+ const finalISRaw = (avg_disc * 0.4) + (avg_rd * 0.3) + (avg_time * 0.2) + (normalizedPnl * 0.1);
386
+ const finalIS = Math.max(0, Math.min(10, finalISRaw));
387
+
388
+ // store in prepared shard result under 'profiles'
389
+ const shardKey = `${SHARD_COLLECTION_NAME}_shard_${shardIndex}`;
390
+ shardedResults[shardKey].profiles[userId] = newHistory;
391
+
392
+ // also set the daily investor score
393
+ dailyInvestorScoreMap[userId] = finalIS;
394
+ }
395
+
396
+ return {
397
+ sharded_user_profile: shardedResults,
398
+ daily_investor_scores: dailyInvestorScoreMap
399
+ };
400
+ }
401
+
402
+ reset() {
403
+ this.dailyUserScores = {};
404
+ this.dependenciesLoaded = false;
405
+ this.priceMap = null;
406
+ this.sectorMap = null;
407
+ this.pnlScores = null;
408
+ this.dates = {};
409
+ this.dependencyLoadedSuccess = false; // <-- MODIFICATION
410
+ }
411
+ }
412
+
413
413
  module.exports = UserInvestmentProfile;