aiden-shared-calculations-unified 1.0.16 → 1.0.18

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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @fileoverview Calculates "Net Crowd Flow" and "Sector Rotation"
3
+ * *only* for the "Dumb Cohort" (Bottom 20% of Investor Scores).
4
+ *
5
+ * This calc depends on 'user-investment-profile.js' being run first for the same day.
6
+ */
7
+
8
+ const { Firestore } = require('@google-cloud/firestore');
9
+ const firestore = new Firestore();
10
+ const { loadAllPriceData, getDailyPriceChange } = require('../../../utils/price_data_provider');
11
+ const { loadInstrumentMappings, getInstrumentSectorMap } = require('../../../utils/sector_mapping_provider');
12
+
13
+ const COHORT_PERCENTILE = 0.2; // Bottom 20%
14
+ const PROFILE_CALC_ID = 'user-investment-profile'; // The calc to read IS scores from
15
+
16
+ class DumbCohortFlow {
17
+ constructor() {
18
+ // Asset Flow
19
+ this.asset_values = {}; // { instrumentId: { day1_value_sum: 0, day2_value_sum: 0 } }
20
+ // Sector Rotation
21
+ this.todaySectorInvestment = {};
22
+ this.yesterdaySectorInvestment = {};
23
+
24
+ this.dumbCohortIds = null; // Set of user IDs
25
+ this.user_count = 0; // Number of *cohort* users
26
+ this.priceMap = null;
27
+ this.mappings = null;
28
+ this.sectorMap = null;
29
+ this.dates = {};
30
+ }
31
+
32
+ /**
33
+ * Loads the Investor Scores, calculates the cohort threshold, and builds the Set of user IDs.
34
+ */
35
+ async _loadCohort(context, dependencies) {
36
+ const { db, logger } = dependencies;
37
+ logger.log('INFO', '[DumbCohortFlow] Loading Investor Scores to build cohort...');
38
+
39
+ try {
40
+ const scoreMapRef = db.collection(context.config.resultsCollection).doc(context.todayDateStr)
41
+ .collection(context.config.resultsSubcollection).doc('behavioural')
42
+ .collection(context.config.computationsSubcollection).doc(PROFILE_CALC_ID);
43
+
44
+ const doc = await scoreMapRef.get();
45
+ if (!doc.exists || !doc.data().daily_investor_scores) {
46
+ logger.log('WARN', '[DumbCohortFlow] Cannot find dependency: daily_investor_scores. Cohort will be empty.');
47
+ this.dumbCohortIds = new Set();
48
+ return;
49
+ }
50
+
51
+ const scores = doc.data().daily_investor_scores;
52
+ const allScores = Object.entries(scores).map(([userId, score]) => ({ userId, score }));
53
+ allScores.sort((a, b) => a.score - b.score);
54
+
55
+ const thresholdIndex = Math.floor(allScores.length * COHORT_PERCENTILE);
56
+ const thresholdScore = allScores[thresholdIndex]?.score || 0; // Get 20th percentile score
57
+
58
+ this.dumbCohortIds = new Set(
59
+ allScores.filter(s => s.score <= thresholdScore).map(s => s.userId) // Get users *at or below*
60
+ );
61
+
62
+ logger.log('INFO', `[DumbCohortFlow] Cohort built. ${this.dumbCohortIds.size} users at or below ${thresholdScore.toFixed(2)} (20th percentile).`);
63
+
64
+ } catch (e) {
65
+ logger.log('ERROR', '[DumbCohortFlow] Failed to load cohort.', { error: e.message });
66
+ this.dumbCohortIds = new Set();
67
+ }
68
+ }
69
+
70
+ // --- Asset Flow Helpers ---
71
+ _initAsset(instrumentId) {
72
+ if (!this.asset_values[instrumentId]) {
73
+ this.asset_values[instrumentId] = { day1_value_sum: 0, day2_value_sum: 0 };
74
+ }
75
+ }
76
+ _sumAssetValue(positions) {
77
+ const valueMap = {};
78
+ if (!positions || !Array.isArray(positions)) return valueMap;
79
+ for (const pos of positions) {
80
+ if (pos && pos.InstrumentID && pos.Value) {
81
+ valueMap[pos.InstrumentID] = (valueMap[pos.InstrumentID] || 0) + pos.Value;
82
+ }
83
+ }
84
+ return valueMap;
85
+ }
86
+ // --- Sector Rotation Helper ---
87
+ _accumulateSectorInvestment(portfolio, target) {
88
+ if (portfolio && portfolio.AggregatedPositions) {
89
+ for (const pos of portfolio.AggregatedPositions) {
90
+ const sector = this.sectorMap[pos.InstrumentID] || 'N/A';
91
+ target[sector] = (target[sector] || 0) + (pos.Invested || pos.Amount || 0);
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * PROCESS: Runs daily for each user.
98
+ */
99
+ async process(todayPortfolio, yesterdayPortfolio, userId, context) {
100
+ // 1. Load cohort on first run
101
+ if (!this.dumbCohortIds) {
102
+ await this._loadCohort(context, context.dependencies);
103
+ this.dates.today = context.todayDateStr;
104
+ this.dates.yesterday = context.yesterdayDateStr;
105
+ }
106
+
107
+ // 2. Filter user
108
+ if (!this.dumbCohortIds.has(userId) || !todayPortfolio || !yesterdayPortfolio || !todayPortfolio.AggregatedPositions || !yesterdayPortfolio.AggregatedPositions) {
109
+ return;
110
+ }
111
+
112
+ // 3. User is in the cohort, load maps if needed
113
+ if (!this.sectorMap) {
114
+ this.sectorMap = await getInstrumentSectorMap();
115
+ }
116
+
117
+ // --- 4. RUN ASSET FLOW LOGIC ---
118
+ const yesterdayValues = this._sumAssetValue(yesterdayPortfolio.AggregatedPositions);
119
+ const todayValues = this._sumAssetValue(todayPortfolio.AggregatedPositions);
120
+ const allInstrumentIds = new Set([...Object.keys(yesterdayValues), ...Object.keys(todayValues)]);
121
+
122
+ for (const instrumentId of allInstrumentIds) {
123
+ this._initAsset(instrumentId);
124
+ this.asset_values[instrumentId].day1_value_sum += (yesterdayValues[instrumentId] || 0);
125
+ this.asset_values[instrumentId].day2_value_sum += (todayValues[instrumentId] || 0);
126
+ }
127
+
128
+ // --- 5. RUN SECTOR ROTATION LOGIC ---
129
+ this._accumulateSectorInvestment(todayPortfolio, this.todaySectorInvestment);
130
+ this._accumulateSectorInvestment(yesterdayPortfolio, this.yesterdaySectorInvestment);
131
+
132
+ this.user_count++;
133
+ }
134
+
135
+ /**
136
+ * GETRESULT: Aggregates and returns the flow data for the cohort.
137
+ */
138
+ async getResult() {
139
+ if (this.user_count === 0 || !this.dates.today) {
140
+ return { asset_flow: {}, sector_rotation: {}, user_sample_size: 0 };
141
+ }
142
+
143
+ // 1. Load dependencies
144
+ if (!this.priceMap || !this.mappings) {
145
+ const [priceData, mappingData] = await Promise.all([
146
+ loadAllPriceData(),
147
+ loadInstrumentMappings()
148
+ ]);
149
+ this.priceMap = priceData;
150
+ this.mappings = mappingData;
151
+ }
152
+
153
+ // --- 2. Calculate Asset Flow ---
154
+ const finalAssetFlow = {};
155
+ const todayStr = this.dates.today;
156
+ const yesterdayStr = this.dates.yesterday;
157
+
158
+ for (const instrumentId in this.asset_values) {
159
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
160
+ const avg_day1_value = this.asset_values[instrumentId].day1_value_sum / this.user_count;
161
+ const avg_day2_value = this.asset_values[instrumentId].day2_value_sum / this.user_count;
162
+ const priceChangePct = getDailyPriceChange(instrumentId, yesterdayStr, todayStr, this.priceMap);
163
+
164
+ if (priceChangePct === null) continue;
165
+
166
+ const expected_day2_value = avg_day1_value * (1 + priceChangePct);
167
+ const net_crowd_flow_pct = avg_day2_value - expected_day2_value;
168
+
169
+ finalAssetFlow[ticker] = {
170
+ net_crowd_flow_pct: net_crowd_flow_pct,
171
+ avg_value_day1_pct: avg_day1_value,
172
+ avg_value_day2_pct: avg_day2_value
173
+ };
174
+ }
175
+
176
+ // --- 3. Calculate Sector Rotation ---
177
+ const finalSectorRotation = {};
178
+ const allSectors = new Set([...Object.keys(this.todaySectorInvestment), ...Object.keys(this.yesterdaySectorInvestment)]);
179
+ for (const sector of allSectors) {
180
+ const todayAmount = this.todaySectorInvestment[sector] || 0;
181
+ const yesterdayAmount = this.yesterdaySectorInvestment[sector] || 0;
182
+ finalSectorRotation[sector] = todayAmount - yesterdayAmount;
183
+ }
184
+
185
+ // 4. Return combined result
186
+ return {
187
+ asset_flow: finalAssetFlow,
188
+ sector_rotation: finalSectorRotation,
189
+ user_sample_size: this.user_count
190
+ };
191
+ }
192
+
193
+ reset() { /* ... reset all constructor properties ... */ }
194
+ }
195
+
196
+ module.exports = DumbCohortFlow;
@@ -0,0 +1,99 @@
1
+ const { loadAllPriceData, getDailyPriceChange } = require('../../../utils/price_data_provider');
2
+ const { loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
3
+
4
+ /**
5
+ * @fileoverview Calculates "Net Crowd Flow" for each asset, BUT
6
+ * *only* for the cohort of users who are currently IN LOSS
7
+ * on their positions for that asset.
8
+ */
9
+ class InLossAssetCrowdFlow {
10
+ constructor() {
11
+ this.asset_values = {}; // Stores { day1_value_sum: 0, day2_value_sum: 0 }
12
+ this.user_count = 0;
13
+ this.priceMap = null;
14
+ this.mappings = null;
15
+ this.dates = {};
16
+ }
17
+
18
+ _initAsset(instrumentId) {
19
+ if (!this.asset_values[instrumentId]) {
20
+ this.asset_values[instrumentId] = { day1_value_sum: 0, day2_value_sum: 0 };
21
+ }
22
+ }
23
+
24
+ process(todayPortfolio, yesterdayPortfolio, userId, context) {
25
+ if (!todayPortfolio || !yesterdayPortfolio || !todayPortfolio.AggregatedPositions || !yesterdayPortfolio.AggregatedPositions) {
26
+ return;
27
+ }
28
+
29
+ if (!this.dates.today && context.todayDateStr && context.yesterdayDateStr) {
30
+ this.dates.today = context.todayDateStr;
31
+ this.dates.yesterday = context.yesterdayDateStr;
32
+ }
33
+
34
+ const yesterdayPositions = new Map(yesterdayPortfolio.AggregatedPositions.map(p => [p.InstrumentID, p]));
35
+ const todayPositions = new Map(todayPortfolio.AggregatedPositions.map(p => [p.InstrumentID, p]));
36
+
37
+ const allInstrumentIds = new Set([
38
+ ...yesterdayPositions.keys(),
39
+ ...todayPositions.keys()
40
+ ]);
41
+
42
+ for (const instrumentId of allInstrumentIds) {
43
+ const yPos = yesterdayPositions.get(instrumentId);
44
+ const tPos = todayPositions.get(instrumentId);
45
+
46
+ // --- COHORT LOGIC ---
47
+ // Only aggregate if the user is in LOSS on this asset.
48
+ const tNetProfit = tPos?.NetProfit || 0;
49
+ if (tNetProfit >= 0) { // Note: >= 0 (includes zero profit)
50
+ continue; // Skip this asset for this user
51
+ }
52
+ // --- END COHORT LOGIC ---
53
+
54
+ this._initAsset(instrumentId);
55
+ this.asset_values[instrumentId].day1_value_sum += (yPos?.Value || 0);
56
+ this.asset_values[instrumentId].day2_value_sum += (tPos?.Value || 0);
57
+ }
58
+ this.user_count++;
59
+ }
60
+
61
+ async getResult() {
62
+ if (this.user_count === 0 || !this.dates.today) return {};
63
+ if (!this.priceMap || !this.mappings) {
64
+ const [priceData, mappingData] = await Promise.all([
65
+ loadAllPriceData(),
66
+ loadInstrumentMappings()
67
+ ]);
68
+ this.priceMap = priceData;
69
+ this.mappings = mappingData;
70
+ }
71
+
72
+ const finalResults = {};
73
+ const todayStr = this.dates.today;
74
+ const yesterdayStr = this.dates.yesterday;
75
+
76
+ for (const instrumentId in this.asset_values) {
77
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
78
+
79
+ const avg_day1_value = this.asset_values[instrumentId].day1_value_sum / this.user_count;
80
+ const avg_day2_value = this.asset_values[instrumentId].day2_value_sum / this.user_count;
81
+ const priceChangePct = getDailyPriceChange(instrumentId, yesterdayStr, todayStr, this.priceMap);
82
+
83
+ if (priceChangePct === null) continue;
84
+
85
+ const expected_day2_value = avg_day1_value * (1 + priceChangePct);
86
+ const net_crowd_flow_pct = avg_day2_value - expected_day2_value;
87
+
88
+ finalResults[ticker] = {
89
+ net_crowd_flow_pct: net_crowd_flow_pct,
90
+ avg_value_day1_pct: avg_day1_value,
91
+ avg_value_day2_pct: avg_day2_value
92
+ };
93
+ }
94
+ return finalResults;
95
+ }
96
+
97
+ reset() { /*...reset all properties...*/ }
98
+ }
99
+ module.exports = InLossAssetCrowdFlow;
@@ -0,0 +1,100 @@
1
+ const { loadAllPriceData, getDailyPriceChange } = require('../../../utils/price_data_provider');
2
+ const { loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
3
+
4
+ /**
5
+ * @fileoverview Calculates "Net Crowd Flow" for each asset, BUT
6
+ * *only* for the cohort of users who are currently IN PROFIT
7
+ * on their positions for that asset.
8
+ */
9
+ class InProfitAssetCrowdFlow {
10
+ constructor() {
11
+ this.asset_values = {}; // Stores { day1_value_sum: 0, day2_value_sum: 0 }
12
+ this.user_count = 0;
13
+ this.priceMap = null;
14
+ this.mappings = null;
15
+ this.dates = {};
16
+ }
17
+
18
+ _initAsset(instrumentId) {
19
+ if (!this.asset_values[instrumentId]) {
20
+ this.asset_values[instrumentId] = { day1_value_sum: 0, day2_value_sum: 0 };
21
+ }
22
+ }
23
+
24
+ process(todayPortfolio, yesterdayPortfolio, userId, context) {
25
+ if (!todayPortfolio || !yesterdayPortfolio || !todayPortfolio.AggregatedPositions || !yesterdayPortfolio.AggregatedPositions) {
26
+ return;
27
+ }
28
+
29
+ if (!this.dates.today && context.todayDateStr && context.yesterdayDateStr) {
30
+ this.dates.today = context.todayDateStr;
31
+ this.dates.yesterday = context.yesterdayDateStr;
32
+ }
33
+
34
+ const yesterdayPositions = new Map(yesterdayPortfolio.AggregatedPositions.map(p => [p.InstrumentID, p]));
35
+ const todayPositions = new Map(todayPortfolio.AggregatedPositions.map(p => [p.InstrumentID, p]));
36
+
37
+ const allInstrumentIds = new Set([
38
+ ...yesterdayPositions.keys(),
39
+ ...todayPositions.keys()
40
+ ]);
41
+
42
+ for (const instrumentId of allInstrumentIds) {
43
+ const yPos = yesterdayPositions.get(instrumentId);
44
+ const tPos = todayPositions.get(instrumentId);
45
+
46
+ // --- COHORT LOGIC ---
47
+ // Only aggregate if the user is in PROFIT on this asset.
48
+ // We check *today's* profit status as the primary signal.
49
+ const tNetProfit = tPos?.NetProfit || 0;
50
+ if (tNetProfit <= 0) {
51
+ continue; // Skip this asset for this user
52
+ }
53
+ // --- END COHORT LOGIC ---
54
+
55
+ this._initAsset(instrumentId);
56
+ this.asset_values[instrumentId].day1_value_sum += (yPos?.Value || 0);
57
+ this.asset_values[instrumentId].day2_value_sum += (tPos?.Value || 0);
58
+ }
59
+ this.user_count++; // Note: This is user_count of *all* users, which is fine for avg.
60
+ }
61
+
62
+ async getResult() {
63
+ if (this.user_count === 0 || !this.dates.today) return {};
64
+ if (!this.priceMap || !this.mappings) {
65
+ const [priceData, mappingData] = await Promise.all([
66
+ loadAllPriceData(),
67
+ loadInstrumentMappings()
68
+ ]);
69
+ this.priceMap = priceData;
70
+ this.mappings = mappingData;
71
+ }
72
+
73
+ const finalResults = {};
74
+ const todayStr = this.dates.today;
75
+ const yesterdayStr = this.dates.yesterday;
76
+
77
+ for (const instrumentId in this.asset_values) {
78
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
79
+
80
+ const avg_day1_value = this.asset_values[instrumentId].day1_value_sum / this.user_count;
81
+ const avg_day2_value = this.asset_values[instrumentId].day2_value_sum / this.user_count;
82
+ const priceChangePct = getDailyPriceChange(instrumentId, yesterdayStr, todayStr, this.priceMap);
83
+
84
+ if (priceChangePct === null) continue;
85
+
86
+ const expected_day2_value = avg_day1_value * (1 + priceChangePct);
87
+ const net_crowd_flow_pct = avg_day2_value - expected_day2_value;
88
+
89
+ finalResults[ticker] = {
90
+ net_crowd_flow_pct: net_crowd_flow_pct,
91
+ avg_value_day1_pct: avg_day1_value,
92
+ avg_value_day2_pct: avg_day2_value
93
+ };
94
+ }
95
+ return finalResults;
96
+ }
97
+
98
+ reset() { /*...reset all properties...*/ }
99
+ }
100
+ module.exports = InProfitAssetCrowdFlow;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @fileoverview Calculates "Net Crowd Flow" and "Sector Rotation"
3
+ * *only* for the "Smart Cohort" (Top 20% of Investor Scores).
4
+ *
5
+ * This calc depends on 'user-investment-profile.js' being run first for the same day.
6
+ */
7
+
8
+ const { Firestore } = require('@google-cloud/firestore');
9
+ const firestore = new Firestore();
10
+ const { loadAllPriceData, getDailyPriceChange } = require('../../../utils/price_data_provider');
11
+ const { loadInstrumentMappings, getInstrumentSectorMap } = require('../../../utils/sector_mapping_provider');
12
+
13
+ const COHORT_PERCENTILE = 0.8; // Top 20%
14
+ const PROFILE_CALC_ID = 'user-investment-profile'; // The calc to read IS scores from
15
+
16
+ class SmartCohortFlow {
17
+ constructor() {
18
+ // Asset Flow
19
+ this.asset_values = {}; // { instrumentId: { day1_value_sum: 0, day2_value_sum: 0 } }
20
+ // Sector Rotation
21
+ this.todaySectorInvestment = {};
22
+ this.yesterdaySectorInvestment = {};
23
+
24
+ this.smartCohortIds = null; // Set of user IDs
25
+ this.user_count = 0; // Number of *cohort* users
26
+ this.priceMap = null;
27
+ this.mappings = null;
28
+ this.sectorMap = null;
29
+ this.dates = {};
30
+ }
31
+
32
+ /**
33
+ * Loads the Investor Scores, calculates the cohort threshold, and builds the Set of user IDs.
34
+ */
35
+ async _loadCohort(context, dependencies) {
36
+ const { db, logger } = dependencies;
37
+ logger.log('INFO', '[SmartCohortFlow] Loading Investor Scores to build cohort...');
38
+
39
+ try {
40
+ const scoreMapRef = db.collection(context.config.resultsCollection).doc(context.todayDateStr)
41
+ .collection(context.config.resultsSubcollection).doc('behavioural')
42
+ .collection(context.config.computationsSubcollection).doc(PROFILE_CALC_ID);
43
+
44
+ const doc = await scoreMapRef.get();
45
+ if (!doc.exists || !doc.data().daily_investor_scores) {
46
+ logger.log('WARN', '[SmartCohortFlow] Cannot find dependency: daily_investor_scores. Cohort will be empty.');
47
+ this.smartCohortIds = new Set();
48
+ return;
49
+ }
50
+
51
+ const scores = doc.data().daily_investor_scores;
52
+ const allScores = Object.entries(scores).map(([userId, score]) => ({ userId, score }));
53
+ allScores.sort((a, b) => a.score - b.score);
54
+
55
+ const thresholdIndex = Math.floor(allScores.length * COHORT_PERCENTILE);
56
+ const thresholdScore = allScores[thresholdIndex]?.score || 999;
57
+
58
+ this.smartCohortIds = new Set(
59
+ allScores.filter(s => s.score >= thresholdScore).map(s => s.userId)
60
+ );
61
+
62
+ logger.log('INFO', `[SmartCohortFlow] Cohort built. ${this.smartCohortIds.size} users at or above ${thresholdScore.toFixed(2)} (80th percentile).`);
63
+
64
+ } catch (e) {
65
+ logger.log('ERROR', '[SmartCohortFlow] Failed to load cohort.', { error: e.message });
66
+ this.smartCohortIds = new Set();
67
+ }
68
+ }
69
+
70
+ // --- Asset Flow Helpers ---
71
+ _initAsset(instrumentId) {
72
+ if (!this.asset_values[instrumentId]) {
73
+ this.asset_values[instrumentId] = { day1_value_sum: 0, day2_value_sum: 0 };
74
+ }
75
+ }
76
+ _sumAssetValue(positions) {
77
+ const valueMap = {};
78
+ if (!positions || !Array.isArray(positions)) return valueMap;
79
+ for (const pos of positions) {
80
+ if (pos && pos.InstrumentID && pos.Value) {
81
+ valueMap[pos.InstrumentID] = (valueMap[pos.InstrumentID] || 0) + pos.Value;
82
+ }
83
+ }
84
+ return valueMap;
85
+ }
86
+ // --- Sector Rotation Helper ---
87
+ _accumulateSectorInvestment(portfolio, target) {
88
+ if (portfolio && portfolio.AggregatedPositions) {
89
+ for (const pos of portfolio.AggregatedPositions) {
90
+ const sector = this.sectorMap[pos.InstrumentID] || 'N/A';
91
+ target[sector] = (target[sector] || 0) + (pos.Invested || pos.Amount || 0);
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * PROCESS: Runs daily for each user.
98
+ */
99
+ async process(todayPortfolio, yesterdayPortfolio, userId, context) {
100
+ // 1. Load cohort on first run
101
+ if (!this.smartCohortIds) {
102
+ await this._loadCohort(context, context.dependencies);
103
+ this.dates.today = context.todayDateStr;
104
+ this.dates.yesterday = context.yesterdayDateStr;
105
+ }
106
+
107
+ // 2. Filter user
108
+ if (!this.smartCohortIds.has(userId) || !todayPortfolio || !yesterdayPortfolio || !todayPortfolio.AggregatedPositions || !yesterdayPortfolio.AggregatedPositions) {
109
+ return;
110
+ }
111
+
112
+ // 3. User is in the cohort, load maps if needed
113
+ if (!this.sectorMap) {
114
+ this.sectorMap = await getInstrumentSectorMap();
115
+ }
116
+
117
+ // --- 4. RUN ASSET FLOW LOGIC ---
118
+ const yesterdayValues = this._sumAssetValue(yesterdayPortfolio.AggregatedPositions);
119
+ const todayValues = this._sumAssetValue(todayPortfolio.AggregatedPositions);
120
+ const allInstrumentIds = new Set([...Object.keys(yesterdayValues), ...Object.keys(todayValues)]);
121
+
122
+ for (const instrumentId of allInstrumentIds) {
123
+ this._initAsset(instrumentId);
124
+ this.asset_values[instrumentId].day1_value_sum += (yesterdayValues[instrumentId] || 0);
125
+ this.asset_values[instrumentId].day2_value_sum += (todayValues[instrumentId] || 0);
126
+ }
127
+
128
+ // --- 5. RUN SECTOR ROTATION LOGIC ---
129
+ this._accumulateSectorInvestment(todayPortfolio, this.todaySectorInvestment);
130
+ this._accumulateSectorInvestment(yesterdayPortfolio, this.yesterdaySectorInvestment);
131
+
132
+ this.user_count++;
133
+ }
134
+
135
+ /**
136
+ * GETRESULT: Aggregates and returns the flow data for the cohort.
137
+ */
138
+ async getResult() {
139
+ if (this.user_count === 0 || !this.dates.today) {
140
+ return { asset_flow: {}, sector_rotation: {}, user_sample_size: 0 };
141
+ }
142
+
143
+ // 1. Load dependencies
144
+ if (!this.priceMap || !this.mappings) {
145
+ const [priceData, mappingData] = await Promise.all([
146
+ loadAllPriceData(),
147
+ loadInstrumentMappings()
148
+ ]);
149
+ this.priceMap = priceData;
150
+ this.mappings = mappingData;
151
+ }
152
+
153
+ // --- 2. Calculate Asset Flow ---
154
+ const finalAssetFlow = {};
155
+ const todayStr = this.dates.today;
156
+ const yesterdayStr = this.dates.yesterday;
157
+
158
+ for (const instrumentId in this.asset_values) {
159
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
160
+ const avg_day1_value = this.asset_values[instrumentId].day1_value_sum / this.user_count;
161
+ const avg_day2_value = this.asset_values[instrumentId].day2_value_sum / this.user_count;
162
+ const priceChangePct = getDailyPriceChange(instrumentId, yesterdayStr, todayStr, this.priceMap);
163
+
164
+ if (priceChangePct === null) continue;
165
+
166
+ const expected_day2_value = avg_day1_value * (1 + priceChangePct);
167
+ const net_crowd_flow_pct = avg_day2_value - expected_day2_value;
168
+
169
+ finalAssetFlow[ticker] = {
170
+ net_crowd_flow_pct: net_crowd_flow_pct,
171
+ avg_value_day1_pct: avg_day1_value,
172
+ avg_value_day2_pct: avg_day2_value
173
+ };
174
+ }
175
+
176
+ // --- 3. Calculate Sector Rotation ---
177
+ const finalSectorRotation = {};
178
+ const allSectors = new Set([...Object.keys(this.todaySectorInvestment), ...Object.keys(this.yesterdaySectorInvestment)]);
179
+ for (const sector of allSectors) {
180
+ const todayAmount = this.todaySectorInvestment[sector] || 0;
181
+ const yesterdayAmount = this.yesterdaySectorInvestment[sector] || 0;
182
+ finalSectorRotation[sector] = todayAmount - yesterdayAmount; // Note: This is total $, not avg.
183
+ }
184
+
185
+ // 4. Return combined result
186
+ return {
187
+ asset_flow: finalAssetFlow,
188
+ sector_rotation: finalSectorRotation,
189
+ user_sample_size: this.user_count
190
+ };
191
+ }
192
+
193
+ reset() { /* ... reset all constructor properties ... */ }
194
+ }
195
+
196
+ module.exports = SmartCohortFlow;