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.
- package/README.MD +77 -77
- package/calculations/activity/historical/activity_by_pnl_status.js +85 -85
- package/calculations/activity/historical/daily_asset_activity.js +85 -85
- package/calculations/activity/historical/daily_user_activity_tracker.js +144 -144
- package/calculations/activity/historical/speculator_adjustment_activity.js +76 -76
- package/calculations/asset_metrics/asset_position_size.js +57 -57
- package/calculations/backtests/strategy-performance.js +229 -245
- package/calculations/behavioural/historical/asset_crowd_flow.js +165 -170
- package/calculations/behavioural/historical/drawdown_response.js +58 -58
- package/calculations/behavioural/historical/dumb-cohort-flow.js +249 -249
- package/calculations/behavioural/historical/gain_response.js +57 -57
- package/calculations/behavioural/historical/in_loss_asset_crowd_flow.js +98 -98
- package/calculations/behavioural/historical/in_profit_asset_crowd_flow.js +99 -99
- package/calculations/behavioural/historical/paper_vs_diamond_hands.js +39 -39
- package/calculations/behavioural/historical/position_count_pnl.js +67 -67
- package/calculations/behavioural/historical/smart-cohort-flow.js +250 -250
- package/calculations/behavioural/historical/smart_money_flow.js +165 -165
- package/calculations/behavioural/historical/user-investment-profile.js +412 -412
- package/calculations/capital_flow/historical/crowd-cash-flow-proxy.js +121 -121
- package/calculations/capital_flow/historical/deposit_withdrawal_percentage.js +117 -117
- package/calculations/capital_flow/historical/new_allocation_percentage.js +49 -49
- package/calculations/insights/daily_bought_vs_sold_count.js +55 -55
- package/calculations/insights/daily_buy_sell_sentiment_count.js +49 -49
- package/calculations/insights/daily_ownership_delta.js +55 -55
- package/calculations/insights/daily_total_positions_held.js +39 -39
- package/calculations/meta/capital_deployment_strategy.js +129 -137
- package/calculations/meta/capital_liquidation_performance.js +121 -163
- package/calculations/meta/capital_vintage_performance.js +121 -158
- package/calculations/meta/cash-flow-deployment.js +110 -124
- package/calculations/meta/cash-flow-liquidation.js +126 -142
- package/calculations/meta/crowd_sharpe_ratio_proxy.js +83 -91
- package/calculations/meta/profit_cohort_divergence.js +77 -91
- package/calculations/meta/smart-dumb-divergence-index.js +116 -138
- package/calculations/meta/social_flow_correlation.js +99 -125
- package/calculations/pnl/asset_pnl_status.js +46 -46
- package/calculations/pnl/historical/profitability_migration.js +57 -57
- package/calculations/pnl/historical/user_profitability_tracker.js +117 -117
- package/calculations/pnl/profitable_and_unprofitable_status.js +64 -64
- package/calculations/sectors/historical/diversification_pnl.js +76 -76
- package/calculations/sectors/historical/sector_rotation.js +67 -67
- package/calculations/sentiment/historical/crowd_conviction_score.js +80 -80
- package/calculations/socialPosts/social-asset-posts-trend.js +52 -52
- package/calculations/socialPosts/social-top-mentioned-words.js +102 -102
- package/calculations/socialPosts/social-topic-interest-evolution.js +53 -53
- package/calculations/socialPosts/social-word-mentions-trend.js +62 -62
- package/calculations/socialPosts/social_activity_aggregation.js +103 -103
- package/calculations/socialPosts/social_event_correlation.js +121 -121
- package/calculations/socialPosts/social_sentiment_aggregation.js +114 -114
- package/calculations/speculators/historical/risk_appetite_change.js +54 -54
- package/calculations/speculators/historical/tsl_effectiveness.js +74 -74
- package/index.js +33 -33
- package/package.json +32 -32
- package/utils/firestore_utils.js +76 -76
- package/utils/price_data_provider.js +142 -142
- package/utils/sector_mapping_provider.js +74 -74
- package/calculations/capital_flow/historical/reallocation_increase_percentage.js +0 -63
- package/calculations/speculators/stop_loss_distance_by_sector_short_long_breakdown.js +0 -91
- 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;
|