aiden-shared-calculations-unified 1.0.35 → 1.0.37

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 -165
  9. package/calculations/behavioural/historical/drawdown_response.js +58 -58
  10. package/calculations/behavioural/historical/dumb-cohort-flow.js +217 -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 +217 -250
  17. package/calculations/behavioural/historical/smart_money_flow.js +165 -165
  18. package/calculations/behavioural/historical/user-investment-profile.js +358 -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,359 @@
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
+ *
4
+ * --- META REFACTOR ---
5
+ * This calculation is now `type: "meta"` to consume in-memory dependencies.
6
+ * It runs ONCE per day, receives the in-memory cache, and must
7
+ * perform its own user data streaming.
8
+ */
9
+
10
+ const { Firestore } = require('@google-cloud/firestore');
11
+ const firestore = new Firestore();
12
+ const { loadAllPriceData, getDailyPriceChange } = require('../../../utils/price_data_provider');
13
+ const { loadInstrumentMappings, getInstrumentSectorMap } = require('../../../utils/sector_mapping_provider');
14
+ const { loadDataByRefs } = require('../../../../bulltrackers-module/functions/computation-system/utils/data_loader'); // Adjust path as needed
15
+
16
+ // Config
17
+ const NUM_SHARDS = 50; // Must match the number of shards to read/write
18
+ const ROLLING_DAYS = 90;
19
+ const SHARD_COLLECTION_NAME = 'user_profile_history'; // The collection to store sharded history
20
+ const PNL_TRACKER_CALC_ID = 'user-profitability-tracker'; // The calc to read PNL from
21
+
22
+ // Helper: stable shard index for numeric or string IDs
23
+ function getShardIndex(id) {
24
+ const n = parseInt(id, 10);
25
+ if (!Number.isNaN(n)) return Math.abs(n) % NUM_SHARDS;
26
+ let h = 0;
27
+ for (let i = 0; i < id.length; i++) {
28
+ h = ((h << 5) - h) + id.charCodeAt(i);
29
+ h |= 0;
30
+ }
31
+ return Math.abs(h) % NUM_SHARDS;
32
+ }
33
+
34
+ class UserInvestmentProfile {
35
+ constructor() {
36
+ // --- META REFACTOR ---
37
+ // All state is now managed inside the `process` function.
38
+ // The constructor, getResult, and reset methods are no longer used
39
+ // by the meta-runner, but we leave them for compatibility.
40
+ // --- END REFACTOR ---
41
+ }
42
+
43
+ // ... [Heuristic calculation functions _calculateRiskAndDivScore, _calculateDisciplineScore, _calculateMarketTimingScore] ...
44
+ // These helper functions remain identical to your original file.
45
+
46
+ /**
47
+ * HEURISTIC 1: Risk & Diversification Score (0-10).
48
+ */
49
+ _calculateRiskAndDivScore(todayPortfolio, sectorMap) {
50
+ if (!todayPortfolio.AggregatedPositions || todayPortfolio.AggregatedPositions.length === 0) {
51
+ return 5; // neutral
52
+ }
53
+
54
+ const positions = todayPortfolio.AggregatedPositions;
55
+ let totalInvested = 0;
56
+ let weightedRetSum = 0;
57
+ let weightedRetSqSum = 0;
58
+ let maxPosition = 0;
59
+ const sectors = new Set();
60
+
61
+ for (const pos of positions) {
62
+ const invested = pos.InvestedAmount || pos.Amount || 0;
63
+ const netProfit = ('NetProfit' in pos) ? pos.NetProfit : (pos.ProfitAndLoss || 0); // decimal % return
64
+ const ret = invested > 0 ? (netProfit) : netProfit; // if invested==0 we still include ret but weight 0
65
+
66
+ weightedRetSum += ret * invested;
67
+ weightedRetSqSum += (ret * ret) * invested;
68
+ totalInvested += invested;
69
+ if (invested > maxPosition) maxPosition = invested;
70
+
71
+ sectors.add(sectorMap[pos.InstrumentID] || 'N/A');
72
+ }
73
+
74
+ // Weighted mean & variance of returns
75
+ const meanReturn = totalInvested > 0 ? (weightedRetSum / totalInvested) : 0;
76
+ const meanReturnSq = totalInvested > 0 ? (weightedRetSqSum / totalInvested) : (meanReturn * meanReturn);
77
+ const variance = Math.max(0, meanReturnSq - (meanReturn * meanReturn));
78
+ const stdReturn = Math.sqrt(variance);
79
+
80
+ // dispersion proxy: mean / std (if std is zero we treat as neutral 0)
81
+ let dispersionRiskProxy = stdReturn > 0 ? meanReturn / stdReturn : 0;
82
+
83
+ // cap and map dispersion proxy to [0..10].
84
+ // dispersionRiskProxy can be outside [-2..4], clamp to reasonable bounds first.
85
+ const capped = Math.max(-2, Math.min(4, dispersionRiskProxy));
86
+ const scoreSharpe = ((capped + 2) / 6) * 10; // maps [-2..4] -> [0..10]
87
+
88
+ // Sector diversification (monotonic - diminishing returns)
89
+ const sectorCount = sectors.size;
90
+ let scoreDiversification = 0;
91
+ if (sectorCount === 1) scoreDiversification = 0;
92
+ else if (sectorCount <= 4) scoreDiversification = 5;
93
+ else if (sectorCount <= 7) scoreDiversification = 8;
94
+ else scoreDiversification = 10;
95
+
96
+ // Position sizing / concentration penalty
97
+ const concentrationRatio = totalInvested > 0 ? (maxPosition / totalInvested) : 0;
98
+ let scoreSizing = 0;
99
+ if (concentrationRatio > 0.8) scoreSizing = 0;
100
+ else if (concentrationRatio > 0.5) scoreSizing = 2;
101
+ else if (concentrationRatio > 0.3) scoreSizing = 5;
102
+ else if (concentrationRatio > 0.15) scoreSizing = 8;
103
+ else scoreSizing = 10;
104
+
105
+ const final = (scoreSharpe * 0.4) + (scoreDiversification * 0.3) + (scoreSizing * 0.3);
106
+ return Math.max(0, Math.min(10, final));
107
+ }
108
+
109
+ /**
110
+ * HEURISTIC 2: Discipline Score (0-10).
111
+ */
112
+ _calculateDisciplineScore(yesterdayPortfolio = {}, todayPortfolio = {}) {
113
+ const yPositions = yesterdayPortfolio.AggregatedPositions || [];
114
+ const tPositions = new Map((todayPortfolio.AggregatedPositions || []).map(p => [p.PositionID, p]));
115
+
116
+ if (yPositions.length === 0) {
117
+ return 5; // neutral if nothing to judge
118
+ }
119
+
120
+ let eventPoints = 0;
121
+ let eventCount = 0;
122
+
123
+ for (const yPos of yPositions) {
124
+ const profitAndLoss = ('ProfitAndLoss' in yPos) ? yPos.ProfitAndLoss : (yPos.NetProfit || 0);
125
+ const invested = yPos.InvestedAmount || yPos.Amount || 0;
126
+ const pnlPercent = profitAndLoss; // This is already the decimal % return
127
+
128
+ const tPos = tPositions.get(yPos.PositionID);
129
+
130
+ if (!tPos) {
131
+ // Closed position
132
+ eventCount++;
133
+ if (pnlPercent < -0.05) eventPoints += 10; // cut loser (good)
134
+ else if (pnlPercent > 0.20) eventPoints += 8; // took big profit (good)
135
+ else if (pnlPercent > 0 && pnlPercent < 0.05) eventPoints += 2; // paper hands (bad)
136
+ else eventPoints += 5; // neutral close
137
+ } else {
138
+ // Held or modified
139
+ if (pnlPercent < -0.10) {
140
+ eventCount++;
141
+ const tInvested = tPos.InvestedAmount || tPos.Amount || 0;
142
+ if (tInvested > invested) eventPoints += 0; // averaged down (very poor)
143
+ else eventPoints += 3; // held loser (poor)
144
+ } else if (pnlPercent > 0.15) {
145
+ eventCount++;
146
+ eventPoints += 10; // held/added to winner (good)
147
+ }
148
+ }
149
+ }
150
+
151
+ const avg = (eventCount > 0) ? (eventPoints / eventCount) : 5;
152
+ return Math.max(0, Math.min(10, avg));
153
+ }
154
+
155
+ /**
156
+ * HEURISTIC 3: Market Timing Score (0-10).
157
+ */
158
+ _calculateMarketTimingScore(yesterdayPortfolio = {}, todayPortfolio = {}, priceMap) {
159
+ const yIds = new Set((yesterdayPortfolio.AggregatedPositions || []).map(p => p.PositionID));
160
+ const newPositions = (todayPortfolio.AggregatedPositions || []).filter(p => !yIds.has(p.PositionID));
161
+
162
+ if (newPositions.length === 0) return 5;
163
+
164
+ let timingPoints = 0;
165
+ let timingCount = 0;
166
+
167
+ for (const tPos of newPositions) {
168
+ const prices = priceMap[tPos.InstrumentID];
169
+ if (!prices) continue;
170
+
171
+ // Accept prices as either array or {date:price} map; build sorted array of prices
172
+ let historyPrices = [];
173
+ if (Array.isArray(prices)) {
174
+ // assume array of numbers or objects with .price/.close
175
+ historyPrices = prices
176
+ .map(p => (typeof p === 'number' ? p : (p.price || p.close || null)))
177
+ .filter(v => v != null);
178
+ } else {
179
+ // object keyed by date -> price
180
+ const entries = Object.keys(prices)
181
+ .map(d => ({ d, p: prices[d] }))
182
+ .filter(e => e.p != null)
183
+ .sort((a, b) => new Date(a.d) - new Date(b.d));
184
+ historyPrices = entries.map(e => e.p);
185
+ }
186
+
187
+ const last30 = historyPrices.slice(-30);
188
+ if (last30.length < 2) continue;
189
+
190
+ const minPrice = Math.min(...last30);
191
+ const maxPrice = Math.max(...last30);
192
+ const openRate = tPos.OpenRate;
193
+ const range = maxPrice - minPrice;
194
+ if (!isFinite(range) || range === 0) continue;
195
+
196
+ let proximity = (openRate - minPrice) / range; // 0 = at low, 1 = at high
197
+ proximity = Math.max(0, Math.min(1, proximity)); // clamp to [0,1]
198
+
199
+ timingCount++;
200
+ if (proximity < 0.2) timingPoints += 10;
201
+ else if (proximity < 0.4) timingPoints += 8;
202
+ else if (proximity > 0.9) timingPoints += 1;
203
+ else if (proximity > 0.7) timingPoints += 3;
204
+ else timingPoints += 5;
205
+ }
206
+
207
+ const avg = (timingCount > 0) ? (timingPoints / timingCount) : 5;
208
+ return Math.max(0, Math.min(10, avg));
209
+ }
210
+
211
+
212
+ /**
213
+ * PROCESS: META REFACTOR
214
+ * This now runs ONCE, loads all data, streams users, and returns one big result.
215
+ */
216
+ async process(dateStr, dependencies, config, computedDependencies) {
217
+ const { logger, db, rootData, calculationUtils } = dependencies;
218
+ const { portfolioRefs } = rootData;
219
+
220
+ logger.log('INFO', '[UserInvestmentProfile] Starting meta-process...');
221
+
222
+ // 1. Get Pass 1 dependency from in-memory cache
223
+ const pnlTrackerResult = computedDependencies[PNL_TRACKER_CALC_ID];
224
+ if (!pnlTrackerResult || !pnlTrackerResult.daily_pnl_map) {
225
+ logger.log('WARN', `[UserInvestmentProfile] Missing in-memory dependency '${PNL_TRACKER_CALC_ID}'. Aborting.`);
226
+ return null; // Return null to signal failure
227
+ }
228
+ const pnlScores = pnlTrackerResult.daily_pnl_map;
229
+ logger.log('INFO', `[UserInvestmentProfile] Received ${Object.keys(pnlScores).length} PNL scores in-memory.`);
230
+
231
+ // 2. Load external dependencies (prices, sectors)
232
+ const [priceMap, sectorMap] = await Promise.all([
233
+ loadAllPriceData(),
234
+ getInstrumentSectorMap()
235
+ ]);
236
+ if (!priceMap || !sectorMap) {
237
+ logger.log('ERROR', '[UserInvestmentProfile] Failed to load priceMap or sectorMap.');
238
+ return null;
239
+ }
240
+
241
+ // 3. Load "yesterday's" portfolio data for comparison
242
+ const yesterdayDate = new Date(dateStr + 'T00:00:00Z');
243
+ yesterdayDate.setUTCDate(yesterdayDate.getUTCDate() - 1);
244
+ const yesterdayStr = yesterdayDate.toISOString().slice(0, 10);
245
+ const yesterdayRefs = await calculationUtils.getPortfolioPartRefs(config, dependencies, yesterdayStr);
246
+ const yesterdayPortfolios = await loadFullDayMap(config, dependencies, yesterdayRefs);
247
+ logger.log('INFO', `[UserInvestmentProfile] Loaded ${yesterdayRefs.length} part refs for yesterday.`);
248
+
249
+ // 4. Stream "today's" portfolio data and process
250
+ const batchSize = config.partRefBatchSize || 10;
251
+ const dailyUserScores = {}; // Local state for this run
252
+
253
+ for (let i = 0; i < portfolioRefs.length; i += batchSize) {
254
+ const batchRefs = portfolioRefs.slice(i, i + batchSize);
255
+ const todayPortfoliosChunk = await loadDataByRefs(config, dependencies, batchRefs);
256
+
257
+ for (const uid in todayPortfoliosChunk) {
258
+ const pToday = todayPortfoliosChunk[uid];
259
+ if (!pToday || !pToday.AggregatedPositions) continue; // Skip speculators or empty
260
+
261
+ const pYesterday = yesterdayPortfolios[uid] || {};
262
+
263
+ // Run the heuristic calculations
264
+ const score_rd = this._calculateRiskAndDivScore(pToday, sectorMap);
265
+ const score_disc = this._calculateDisciplineScore(pYesterday, pToday);
266
+ const score_time = this._calculateMarketTimingScore(pYesterday, pToday, priceMap);
267
+
268
+ dailyUserScores[uid] = {
269
+ score_rd,
270
+ score_disc,
271
+ score_time
272
+ };
273
+ }
274
+ }
275
+ logger.log('INFO', `[UserInvestmentProfile] Calculated daily scores for ${Object.keys(dailyUserScores).length} users.`);
276
+
277
+ // --- 5. GETRESULT LOGIC IS NOW INSIDE PROCESS ---
278
+ // (This is the logic from your original getResult())
279
+
280
+ const shardedResults = {};
281
+ for (let i = 0; i < NUM_SHARDS; i++) {
282
+ const shardKey = `${SHARD_COLLECTION_NAME}_shard_${i}`;
283
+ shardedResults[shardKey] = { profiles: {}, lastUpdated: dateStr };
284
+ }
285
+
286
+ const dailyInvestorScoreMap = {};
287
+
288
+ // Fetch existing shards in parallel
289
+ const shardPromises = [];
290
+ for (let i = 0; i < NUM_SHARDS; i++) {
291
+ const docRef = firestore.collection(SHARD_COLLECTION_NAME).doc(`${SHARD_COLLECTION_NAME}_shard_${i}`);
292
+ shardPromises.push(docRef.get());
293
+ }
294
+ const shardSnapshots = await Promise.all(shardPromises);
295
+ const existingShards = shardSnapshots.map((snap) => (snap.exists ? snap.data().profiles : {}));
296
+
297
+ // Process users
298
+ for (const userId of Object.keys(dailyUserScores)) {
299
+ const shardIndex = getShardIndex(userId);
300
+ const scores = dailyUserScores[userId];
301
+
302
+ const existingProfiles = existingShards[shardIndex] || {};
303
+ const history = (existingProfiles[userId] || []).slice(); // clone
304
+
305
+ history.push({
306
+ date: dateStr,
307
+ ...scores,
308
+ pnl: (pnlScores[userId] || 0)
309
+ });
310
+
311
+ const newHistory = history.slice(-ROLLING_DAYS);
312
+
313
+ // compute rolling averages
314
+ let avg_rd = 0, avg_disc = 0, avg_time = 0, avg_pnl = 0;
315
+ for (const entry of newHistory) {
316
+ avg_rd += (entry.score_rd || 0);
317
+ avg_disc += (entry.score_disc || 0);
318
+ avg_time += (entry.score_time || 0);
319
+ avg_pnl += (entry.pnl || 0);
320
+ }
321
+ const N = newHistory.length || 1;
322
+ avg_rd /= N;
323
+ avg_disc /= N;
324
+ avg_time /= N;
325
+ avg_pnl /= N;
326
+
327
+ const normalizedPnl = Math.max(-10, Math.min(10, avg_pnl * 1000));
328
+ const finalISRaw = (avg_disc * 0.4) + (avg_rd * 0.3) + (avg_time * 0.2) + (normalizedPnl * 0.1);
329
+ const finalIS = Math.max(0, Math.min(10, finalISRaw));
330
+
331
+ const shardKey = `${SHARD_COLLECTION_NAME}_shard_${shardIndex}`;
332
+ shardedResults[shardKey].profiles[userId] = newHistory;
333
+ dailyInvestorScoreMap[userId] = finalIS;
334
+ }
335
+
336
+ logger.log('INFO', `[UserInvestmentProfile] Finalized IS scores for ${Object.keys(dailyInvestorScoreMap).length} users.`);
337
+
338
+ // Return the final result object
339
+ return {
340
+ sharded_user_profile: shardedResults,
341
+ daily_investor_scores: dailyInvestorScoreMap
342
+ };
343
+ }
344
+
345
+ /**
346
+ * getResult is no longer used by the meta-runner.
347
+ */
348
+ async getResult() {
349
+ return null;
350
+ }
351
+
352
+ /**
353
+ * reset is no longer used by the meta-runner.
354
+ */
355
+ reset() {
356
+ }
357
+ }
358
+
413
359
  module.exports = UserInvestmentProfile;