aiden-shared-calculations-unified 1.0.72 → 1.0.73

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.
@@ -102,8 +102,8 @@ class AssetCrowdFlow {
102
102
  const yP = yPosMap.get(instrumentId);
103
103
  const tP = tPosMap.get(instrumentId);
104
104
 
105
- const yInvested = yP?.InvestedAmount || yP?.Amount || 0;
106
- const tInvested = tP?.InvestedAmount || tP?.Amount || 0;
105
+ const yInvested = yP?.Invested || 0;
106
+ const tInvested = tP?.Invested || 0;
107
107
 
108
108
  if (yInvested > 0) {
109
109
  asset.total_invested_yesterday += yInvested;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 1) for defining skill-based cohorts.
3
+ *
4
+ * This metric calculates a long-term "Skill Score" for each user based on
5
+ * their closed trade history (win rate, profit/loss ratio, and trade count).
6
+ *
7
+ * To remain compact, it processes all users, ranks them, and returns
8
+ * only the list of user IDs for the top and bottom 20% cohorts.
9
+ */
10
+ class CohortSkillDefinition {
11
+ constructor() {
12
+ // { userId: { skill_score: 12.3 } }
13
+ this.userScores = new Map();
14
+ }
15
+
16
+ /**
17
+ * Defines the output schema for this calculation.
18
+ * @returns {object} JSON Schema object
19
+ */
20
+ static getSchema() {
21
+ return {
22
+ "type": "object",
23
+ "description": "Provides the user ID lists for the 'Skilled' (top 20%) and 'Unskilled' (bottom 20%) cohorts based on historical trade performance.",
24
+ "properties": {
25
+ "skilled_user_ids": {
26
+ "type": "array",
27
+ "description": "List of user IDs in the top 20% 'Skilled' cohort.",
28
+ "items": { "type": "string" }
29
+ },
30
+ "unskilled_user_ids": {
31
+ "type": "array",
32
+ "description": "List of user IDs in the bottom 20% 'Unskilled' cohort.",
33
+ "items": { "type": "string" }
34
+ },
35
+ "skilled_cohort_size": { "type": "number" },
36
+ "unskilled_cohort_size": { "type": "number" }
37
+ },
38
+ "required": ["skilled_user_ids", "unskilled_user_ids", "skilled_cohort_size", "unskilled_cohort_size"]
39
+ };
40
+ }
41
+
42
+ /**
43
+ * This is a Pass 1 calculation and has no dependencies.
44
+ */
45
+ static getDependencies() {
46
+ return [];
47
+ }
48
+
49
+ /**
50
+ * Process data from the 'history' root data source.
51
+ * @param {object} rootData - The root data object from the runner.
52
+ * @param {string} userId - The user ID.
53
+ */
54
+ process(rootData, userId) {
55
+ const history = rootData?.history?.all;
56
+ if (!history) {
57
+ return; // Skip user if they have no history data
58
+ }
59
+
60
+ const { winRatio, avgProfitPct, avgLossPct, totalTrades } = history;
61
+
62
+ if (!totalTrades || totalTrades < 10) {
63
+ return; // Skip users with too few trades for a reliable score
64
+ }
65
+
66
+ // Calculate Expectancy: (Win % * Avg Win %) - (Loss % * Avg Loss %)
67
+ const winRate = winRatio / 100.0;
68
+ const lossRate = 1.0 - winRate;
69
+ const avgWin = avgProfitPct; // Already in %
70
+ const avgLoss = Math.abs(avgLossPct); // Already in %
71
+
72
+ // This score is "percentage points gained per trade"
73
+ const expectancy = (winRate * avgWin) - (lossRate * avgLoss);
74
+
75
+ // Weight by log(trades) to value experience, clamped to avoid log(0)
76
+ const skillScore = expectancy * Math.log10(Math.max(1, totalTrades));
77
+
78
+ if (isFinite(skillScore)) {
79
+ this.userScores.set(userId, skillScore);
80
+ }
81
+ }
82
+
83
+ getResult() {
84
+ const sortedUsers = Array.from(this.userScores.entries())
85
+ // Sort descending by skill score
86
+ .sort((a, b) => b[1] - a[1]);
87
+
88
+ const cohortSize = Math.floor(sortedUsers.length * 0.20);
89
+ if (cohortSize === 0) {
90
+ return { skilled_user_ids: [], unskilled_user_ids: [], skilled_cohort_size: 0, unskilled_cohort_size: 0 };
91
+ }
92
+
93
+ const skilled_user_ids = sortedUsers.slice(0, cohortSize).map(u => u[0]);
94
+ const unskilled_user_ids = sortedUsers.slice(-cohortSize).map(u => u[0]);
95
+
96
+ return {
97
+ skilled_user_ids: skilled_user_ids,
98
+ unskilled_user_ids: unskilled_user_ids,
99
+ skilled_cohort_size: skilled_user_ids.length,
100
+ unskilled_cohort_size: unskilled_user_ids.length
101
+ };
102
+ }
103
+
104
+ reset() {
105
+ this.userScores.clear();
106
+ }
107
+ }
108
+
109
+ module.exports = CohortSkillDefinition;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 2) for "Skilled Cohort" flow.
3
+ *
4
+ * This metric calculates the "Net Crowd Flow Percentage" for the
5
+ * "Skilled Cohort" (top 20% by long-term skill score).
6
+ *
7
+ * This calculation *depends* on 'cohort-skill-definition'
8
+ * to identify the cohort.
9
+ */
10
+ const { loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
11
+
12
+ class SkilledCohortFlow {
13
+ constructor() {
14
+ this.assetData = new Map();
15
+ this.sectorData = new Map();
16
+ this.mappings = null;
17
+ this.skilledCohortUserIds = null;
18
+ }
19
+
20
+ /**
21
+ * Defines the output schema for this calculation.
22
+ * @returns {object} JSON Schema object
23
+ */
24
+ static getSchema() {
25
+ const flowSchema = {
26
+ "type": "object",
27
+ "properties": {
28
+ "net_flow_percentage": { "type": "number" },
29
+ "total_invested_today": { "type": "number" },
30
+ "total_invested_yesterday": { "type": "number" }
31
+ },
32
+ "required": ["net_flow_percentage", "total_invested_today", "total_invested_yesterday"]
33
+ };
34
+
35
+ return {
36
+ "type": "object",
37
+ "description": "Calculates net capital flow % (price-adjusted) for the 'Skilled Cohort' (top 20% by skill score), aggregated by asset and sector.",
38
+ "properties": {
39
+ "cohort_size": {
40
+ "type": "number",
41
+ "description": "The number of users identified as being in the Skilled Cohort."
42
+ },
43
+ "assets": {
44
+ "type": "object",
45
+ "description": "Price-adjusted net flow per asset.",
46
+ "patternProperties": { "^.*$": flowSchema }, // Ticker
47
+ "additionalProperties": flowSchema
48
+ },
49
+ "sectors": {
50
+ "type": "object",
51
+ "description": "Price-adjusted net flow per sector.",
52
+ "patternProperties": { "^.*$": flowSchema }, // Sector
53
+ "additionalProperties": flowSchema
54
+ }
55
+ },
56
+ "required": ["cohort_size", "assets", "sectors"]
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Statically declare dependencies.
62
+ */
63
+ static getDependencies() {
64
+ return ['gem_cohort-skill-definition']; // Pass 1
65
+ }
66
+
67
+ _getPortfolioPositions(portfolio) {
68
+ return portfolio?.PublicPositions || portfolio?.AggregatedPositions;
69
+ }
70
+
71
+ _initAsset(instrumentId) {
72
+ if (!this.assetData.has(instrumentId)) {
73
+ this.assetData.set(instrumentId, {
74
+ total_invested_yesterday: 0,
75
+ total_invested_today: 0,
76
+ price_change_yesterday: 0,
77
+ });
78
+ }
79
+ }
80
+
81
+ _initSector(sector) {
82
+ if (!this.sectorData.has(sector)) {
83
+ this.sectorData.set(sector, {
84
+ total_invested_yesterday: 0,
85
+ total_invested_today: 0,
86
+ price_change_yesterday: 0,
87
+ });
88
+ }
89
+ }
90
+
91
+ _getSkilledCohort(fetchedDependencies) {
92
+ if (this.skilledCohortUserIds) {
93
+ return this.skilledCohortUserIds;
94
+ }
95
+
96
+ const cohortData = fetchedDependencies['cohort-skill-definition'];
97
+ if (!cohortData || !cohortData.skilled_user_ids) {
98
+ return new Set();
99
+ }
100
+
101
+ this.skilledCohortUserIds = new Set(cohortData.skilled_user_ids);
102
+ return this.skilledCohortUserIds;
103
+ }
104
+
105
+ process(todayPortfolio, yesterdayPortfolio, userId, context, todayInsights, yesterdayInsights, fetchedDependencies) {
106
+ const skilledCohort = this._getSkilledCohort(fetchedDependencies);
107
+
108
+ if (!skilledCohort.has(userId)) {
109
+ return; // This user is not in the "skilled cohort", skip.
110
+ }
111
+
112
+ if (!todayPortfolio || !yesterdayPortfolio) {
113
+ return;
114
+ }
115
+
116
+ // Logic from here is identical to smart-cohort-flow.js
117
+ const yPos = this._getPortfolioPositions(yesterdayPortfolio);
118
+ const tPos = this._getPortfolioPositions(todayPortfolio);
119
+ const yPosMap = new Map(yPos?.map(p => [p.InstrumentID, p]) || []);
120
+ const tPosMap = new Map(tPos?.map(p => [p.InstrumentID, p]) || []);
121
+ const allInstrumentIds = new Set([...yPosMap.keys(), ...tPosMap.keys()]);
122
+
123
+ if (!this.mappings) {
124
+ this.mappings = context.mappings;
125
+ }
126
+
127
+ for (const instrumentId of allInstrumentIds) {
128
+ if (!instrumentId) continue;
129
+
130
+ this._initAsset(instrumentId);
131
+ const asset = this.assetData.get(instrumentId);
132
+ const yP = yPosMap.get(instrumentId);
133
+ const tP = tPosMap.get(instrumentId);
134
+
135
+ // Use 'Invested' for % portfolio, not 'Amount'
136
+ const yInvested = yP?.Invested || 0;
137
+ const tInvested = tP?.Invested || 0;
138
+
139
+ const sector = this.mappings.instrumentToSector[instrumentId] || 'Other';
140
+ this._initSector(sector);
141
+ const sectorAsset = this.sectorData.get(sector);
142
+
143
+ if (yInvested > 0) {
144
+ // Use P&L % from portfolio 'NetProfit'
145
+ const yPriceChange = (yP?.NetProfit || 0) / (yP?.Invested || 1);
146
+
147
+ asset.total_invested_yesterday += yInvested;
148
+ asset.price_change_yesterday += yPriceChange * yInvested;
149
+
150
+ sectorAsset.total_invested_yesterday += yInvested;
151
+ sectorAsset.price_change_yesterday += yPriceChange * yInvested;
152
+ }
153
+ if (tInvested > 0) {
154
+ asset.total_invested_today += tInvested;
155
+ sectorAsset.total_invested_today += tInvested;
156
+ }
157
+ }
158
+ }
159
+
160
+ _calculateFlow(dataMap) {
161
+ const result = {};
162
+ for (const [key, data] of dataMap.entries()) {
163
+ const { total_invested_yesterday, total_invested_today, price_change_yesterday } = data;
164
+
165
+ if (total_invested_yesterday > 0) {
166
+ const avg_price_change_pct = price_change_yesterday / total_invested_yesterday;
167
+ const price_contribution = total_invested_yesterday * avg_price_change_pct;
168
+ const flow_contribution = total_invested_today - (total_invested_yesterday + price_contribution);
169
+ const net_flow_percentage = (flow_contribution / total_invested_yesterday) * 100;
170
+
171
+ result[key] = {
172
+ net_flow_percentage: net_flow_percentage,
173
+ total_invested_today: total_invested_today,
174
+ total_invested_yesterday: total_invested_yesterday
175
+ };
176
+ }
177
+ }
178
+ return result;
179
+ }
180
+
181
+ async getResult(fetchedDependencies) {
182
+ if (!this.mappings) {
183
+ this.mappings = await loadInstrumentMappings();
184
+ }
185
+
186
+ const skilledCohort = this._getSkilledCohort(fetchedDependencies);
187
+
188
+ // 1. Calculate Asset Flow
189
+ const assetResult = {};
190
+ for (const [instrumentId, data] of this.assetData.entries()) {
191
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
192
+ const { total_invested_yesterday, total_invested_today, price_change_yesterday } = data;
193
+
194
+ if (total_invested_yesterday > 0) {
195
+ const avg_price_change_pct = price_change_yesterday / total_invested_yesterday;
196
+ const price_contribution = total_invested_yesterday * avg_price_change_pct;
197
+ const flow_contribution = total_invested_today - (total_invested_yesterday + price_contribution);
198
+ const net_flow_percentage = (flow_contribution / total_invested_yesterday) * 100;
199
+
200
+ assetResult[ticker] = {
201
+ net_flow_percentage: net_flow_percentage,
202
+ total_invested_today: total_invested_today,
203
+ total_invested_yesterday: total_invested_yesterday
204
+ };
205
+ }
206
+ }
207
+
208
+ // 2. Calculate Sector Flow
209
+ const sectorResult = this._calculateFlow(this.sectorData);
210
+
211
+ return {
212
+ cohort_size: skilledCohort.size,
213
+ assets: assetResult,
214
+ sectors: sectorResult
215
+ };
216
+ }
217
+
218
+ reset() {
219
+ this.assetData.clear();
220
+ this.sectorData.clear();
221
+ this.mappings = null;
222
+ this.skilledCohortUserIds = null;
223
+ }
224
+ }
225
+
226
+ module.exports = SkilledCohortFlow;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 2) for "Unskilled Cohort" flow.
3
+ *
4
+ * This metric calculates the "Net Crowd Flow Percentage" for the
5
+ * "Unskilled Cohort" (bottom 20% by long-term skill score).
6
+ *
7
+ * This calculation *depends* on 'cohort-skill-definition'
8
+ * to identify the cohort.
9
+ */
10
+ const { loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
11
+
12
+ class UnskilledCohortFlow {
13
+ constructor() {
14
+ this.assetData = new Map();
15
+ this.sectorData = new Map();
16
+ this.mappings = null;
17
+ this.unskilledCohortUserIds = null;
18
+ }
19
+
20
+ /**
21
+ * Defines the output schema for this calculation.
22
+ * @returns {object} JSON Schema object
23
+ */
24
+ static getSchema() {
25
+ const flowSchema = {
26
+ "type": "object",
27
+ "properties": {
28
+ "net_flow_percentage": { "type": "number" },
29
+ "total_invested_today": { "type": "number" },
30
+ "total_invested_yesterday": { "type": "number" }
31
+ },
32
+ "required": ["net_flow_percentage", "total_invested_today", "total_invested_yesterday"]
33
+ };
34
+
35
+ return {
36
+ "type": "object",
37
+ "description": "Calculates net capital flow % (price-adjusted) for the 'Unskilled Cohort' (bottom 20% by skill score), aggregated by asset and sector.",
38
+ "properties": {
39
+ "cohort_size": {
40
+ "type": "number",
41
+ "description": "The number of users identified as being in the Unskilled Cohort."
42
+ },
43
+ "assets": {
44
+ "type": "object",
45
+ "description": "Price-adjusted net flow per asset.",
46
+ "patternProperties": { "^.*$": flowSchema }, // Ticker
47
+ "additionalProperties": flowSchema
48
+ },
49
+ "sectors": {
50
+ "type": "object",
51
+ "description": "Price-adjusted net flow per sector.",
52
+ "patternProperties": { "^.*$": flowSchema }, // Sector
53
+ "additionalProperties": flowSchema
54
+ }
55
+ },
56
+ "required": ["cohort_size", "assets", "sectors"]
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Statically declare dependencies.
62
+ */
63
+ static getDependencies() {
64
+ return ['gem_cohort-skill-definition']; // Pass 1
65
+ }
66
+
67
+ _getPortfolioPositions(portfolio) {
68
+ return portfolio?.PublicPositions || portfolio?.AggregatedPositions;
69
+ }
70
+
71
+ _initAsset(instrumentId) {
72
+ if (!this.assetData.has(instrumentId)) {
73
+ this.assetData.set(instrumentId, {
74
+ total_invested_yesterday: 0,
75
+ total_invested_today: 0,
76
+ price_change_yesterday: 0,
77
+ });
78
+ }
79
+ }
80
+
81
+ _initSector(sector) {
82
+ if (!this.sectorData.has(sector)) {
83
+ this.sectorData.set(sector, {
84
+ total_invested_yesterday: 0,
85
+ total_invested_today: 0,
86
+ price_change_yesterday: 0,
87
+ });
88
+ }
89
+ }
90
+
91
+ _getUnskilledCohort(fetchedDependencies) {
92
+ if (this.unskilledCohortUserIds) {
93
+ return this.unskilledCohortUserIds;
94
+ }
95
+
96
+ const cohortData = fetchedDependencies['cohort-skill-definition'];
97
+ if (!cohortData || !cohortData.unskilled_user_ids) {
98
+ return new Set();
99
+ }
100
+
101
+ this.unskilledCohortUserIds = new Set(cohortData.unskilled_user_ids);
102
+ return this.unskilledCohortUserIds;
103
+ }
104
+
105
+ process(todayPortfolio, yesterdayPortfolio, userId, context, todayInsights, yesterdayInsights, fetchedDependencies) {
106
+ const unskilledCohort = this._getUnskilledCohort(fetchedDependencies);
107
+
108
+ if (!unskilledCohort.has(userId)) {
109
+ return; // This user is not in the "unskilled cohort", skip.
110
+ }
111
+
112
+ if (!todayPortfolio || !yesterdayPortfolio) {
113
+ return;
114
+ }
115
+
116
+ // Logic from here is identical to smart-cohort-flow.js
117
+ const yPos = this._getPortfolioPositions(yesterdayPortfolio);
118
+ const tPos = this._getPortfolioPositions(todayPortfolio);
119
+ const yPosMap = new Map(yPos?.map(p => [p.InstrumentID, p]) || []);
120
+ const tPosMap = new Map(tPos?.map(p => [p.InstrumentID, p]) || []);
121
+ const allInstrumentIds = new Set([...yPosMap.keys(), ...tPosMap.keys()]);
122
+
123
+ if (!this.mappings) {
124
+ this.mappings = context.mappings;
125
+ }
126
+
127
+ for (const instrumentId of allInstrumentIds) {
128
+ if (!instrumentId) continue;
129
+
130
+ this._initAsset(instrumentId);
131
+ const asset = this.assetData.get(instrumentId);
132
+ const yP = yPosMap.get(instrumentId);
133
+ const tP = tPosMap.get(instrumentId);
134
+
135
+ const yInvested = yP?.Invested || 0;
136
+ const tInvested = tP?.Invested || 0;
137
+
138
+ const sector = this.mappings.instrumentToSector[instrumentId] || 'Other';
139
+ this._initSector(sector);
140
+ const sectorAsset = this.sectorData.get(sector);
141
+
142
+ if (yInvested > 0) {
143
+ const yPriceChange = (yP?.NetProfit || 0) / (yP?.Invested || 1);
144
+
145
+ asset.total_invested_yesterday += yInvested;
146
+ asset.price_change_yesterday += yPriceChange * yInvested;
147
+
148
+ sectorAsset.total_invested_yesterday += yInvested;
149
+ sectorAsset.price_change_yesterday += yPriceChange * yInvested;
150
+ }
151
+ if (tInvested > 0) {
152
+ asset.total_invested_today += tInvested;
153
+ sectorAsset.total_invested_today += tInvested;
154
+ }
155
+ }
156
+ }
157
+
158
+ _calculateFlow(dataMap) {
159
+ // This helper is identical to the one in skilled-cohort-flow
160
+ const result = {};
161
+ for (const [key, data] of dataMap.entries()) {
162
+ const { total_invested_yesterday, total_invested_today, price_change_yesterday } = data;
163
+
164
+ if (total_invested_yesterday > 0) {
165
+ const avg_price_change_pct = price_change_yesterday / total_invested_yesterday;
166
+ const price_contribution = total_invested_yesterday * avg_price_change_pct;
167
+ const flow_contribution = total_invested_today - (total_invested_yesterday + price_contribution);
168
+ const net_flow_percentage = (flow_contribution / total_invested_yesterday) * 100;
169
+
170
+ result[key] = {
171
+ net_flow_percentage: net_flow_percentage,
172
+ total_invested_today: total_invested_today,
173
+ total_invested_yesterday: total_invested_yesterday
174
+ };
175
+ }
176
+ }
177
+ return result;
178
+ }
179
+
180
+ async getResult(fetchedDependencies) {
181
+ if (!this.mappings) {
182
+ this.mappings = await loadInstrumentMappings();
183
+ }
184
+
185
+ const unskilledCohort = this._getUnskilledCohort(fetchedDependencies);
186
+
187
+ // 1. Calculate Asset Flow
188
+ const assetResult = {};
189
+ for (const [instrumentId, data] of this.assetData.entries()) {
190
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
191
+ const { total_invested_yesterday, total_invested_today, price_change_yesterday } = data;
192
+
193
+ if (total_invested_yesterday > 0) {
194
+ const avg_price_change_pct = price_change_yesterday / total_invested_yesterday;
195
+ const price_contribution = total_invested_yesterday * avg_price_change_pct;
196
+ const flow_contribution = total_invested_today - (total_invested_yesterday + price_contribution);
197
+ const net_flow_percentage = (flow_contribution / total_invested_yesterday) * 100;
198
+
199
+ assetResult[ticker] = {
200
+ net_flow_percentage: net_flow_percentage,
201
+ total_invested_today: total_invested_today,
202
+ total_invested_yesterday: total_invested_yesterday
203
+ };
204
+ }
205
+ }
206
+
207
+ // 2. Calculate Sector Flow
208
+ const sectorResult = this._calculateFlow(this.sectorData);
209
+
210
+ return {
211
+ cohort_size: unskilledCohort.size,
212
+ assets: assetResult,
213
+ sectors: sectorResult
214
+ };
215
+ }
216
+
217
+ reset() {
218
+ this.assetData.clear();
219
+ this.sectorData.clear();
220
+ this.mappings = null;
221
+ this.unskilledCohortUserIds = null;
222
+ }
223
+ }
224
+
225
+ module.exports = UnskilledCohortFlow;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 3) for cohort momentum state.
3
+ *
4
+ * This metric answers: "Are the 'Skilled' and 'Unskilled' cohorts
5
+ * trend-following or acting as contrarians?"
6
+ *
7
+ * It multiplies cohort flow by price momentum.
8
+ * - High positive score = Buying into a rally (FOMO)
9
+ * - High negative score = Buying into a dip, or Selling into a rally
10
+ *
11
+ * It *depends* on Pass 2 cohort flows and Pass 1 price momentum.
12
+ */
13
+ class CohortMomentumState {
14
+ constructor() {
15
+ // No per-user processing
16
+ }
17
+
18
+ /**
19
+ * Defines the output schema for this calculation.
20
+ * @returns {object} JSON Schema object
21
+ */
22
+ static getSchema() {
23
+ const tickerSchema = {
24
+ "type": "object",
25
+ "properties": {
26
+ "skilled_momentum_score": {
27
+ "type": "number",
28
+ "description": "Skilled Flow % * 20d Momentum %. High positive = trend-following."
29
+ },
30
+ "unskilled_momentum_score": {
31
+ "type": "number",
32
+ "description": "Unskilled Flow % * 20d Momentum %. High positive = trend-following (FOMO)."
33
+ },
34
+ "skilled_flow_pct": { "type": "number" },
35
+ "unskilled_flow_pct": { "type": "number" },
36
+ "momentum_20d_pct": { "type": ["number", "null"] }
37
+ },
38
+ "required": ["skilled_momentum_score", "unskilled_momentum_score"]
39
+ };
40
+
41
+ return {
42
+ "type": "object",
43
+ "description": "Calculates a momentum-following score for Skilled and Unskilled cohorts.",
44
+ "patternProperties": {
45
+ "^.*$": tickerSchema // Ticker
46
+ },
47
+ "additionalProperties": tickerSchema
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Statically declare dependencies.
53
+ */
54
+ static getDependencies() {
55
+ return [
56
+ 'gem_skilled-cohort-flow', // Pass 2
57
+ 'gem_unskilled-cohort-flow', // Pass 2
58
+ 'gem_instrument-price-momentum' // Pass 1
59
+ ];
60
+ }
61
+
62
+ process() {
63
+ // No-op
64
+ }
65
+
66
+ /**
67
+ * This is a 'meta' calculation.
68
+ * @param {object} fetchedDependencies - Results from previous passes.
69
+ */
70
+ getResult(fetchedDependencies) {
71
+ const skilledFlowData = fetchedDependencies['skilled-cohort-flow']?.assets;
72
+ const unskilledFlowData = fetchedDependencies['unskilled-cohort-flow']?.assets;
73
+ const momentumData = fetchedDependencies['instrument-price-momentum'];
74
+
75
+ if (!skilledFlowData || !unskilledFlowData || !momentumData) {
76
+ return {};
77
+ }
78
+
79
+ const result = {};
80
+ const allTickers = new Set([
81
+ ...Object.keys(skilledFlowData),
82
+ ...Object.keys(unskilledFlowData)
83
+ ]);
84
+
85
+ for (const ticker of allTickers) {
86
+ const sFlow = skilledFlowData[ticker]?.net_flow_percentage || 0;
87
+ const uFlow = unskilledFlowData[ticker]?.net_flow_percentage || 0;
88
+ const mom = momentumData[ticker]?.momentum_20d_pct || 0;
89
+
90
+ const skilled_momentum_score = sFlow * mom;
91
+ const unskilled_momentum_score = uFlow * mom;
92
+
93
+ result[ticker] = {
94
+ skilled_momentum_score: skilled_momentum_score,
95
+ unskilled_momentum_score: unskilled_momentum_score,
96
+ skilled_flow_pct: sFlow,
97
+ unskilled_flow_pct: uFlow,
98
+ momentum_20d_pct: mom
99
+ };
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ reset() {
106
+ // No state
107
+ }
108
+ }
109
+
110
+ module.exports = CohortMomentumState;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 1 - Meta) for 20-day price momentum.
3
+ *
4
+ * This metric answers: "What is the 20-day percentage price change for
5
+ * every instrument?"
6
+ *
7
+ * It is a 'meta' calculation that runs once, loads all price data,
8
+ * and provides a reusable momentum signal for downstream passes.
9
+ */
10
+ const { loadAllPriceData } = require('../../utils/price_data_provider');
11
+
12
+ class InstrumentPriceMomentum {
13
+
14
+ /**
15
+ * Defines the output schema for this calculation.
16
+ * @returns {object} JSON Schema object
17
+ */
18
+ static getSchema() {
19
+ const tickerSchema = {
20
+ "type": "object",
21
+ "properties": {
22
+ "momentum_20d_pct": {
23
+ "type": ["number", "null"],
24
+ "description": "The 20-day rolling price change percentage."
25
+ }
26
+ },
27
+ "required": ["momentum_20d_pct"]
28
+ };
29
+
30
+ return {
31
+ "type": "object",
32
+ "description": "Calculates the 20-day price momentum for all instruments.",
33
+ "patternProperties": {
34
+ "^.*$": tickerSchema // Ticker
35
+ },
36
+ "additionalProperties": tickerSchema
37
+ };
38
+ }
39
+
40
+ /**
41
+ * This is a Pass 1 calculation and has no dependencies.
42
+ */
43
+ static getDependencies() {
44
+ return [];
45
+ }
46
+
47
+ // Helper to get date string N days ago
48
+ _getDateStr(baseDateStr, daysOffset) {
49
+ const date = new Date(baseDateStr + 'T00:00:00Z');
50
+ date.setUTCDate(date.getUTCDate() + daysOffset);
51
+ return date.toISOString().slice(0, 10);
52
+ }
53
+
54
+ // Helper to find the last available price on or before a date
55
+ _findPrice(priceHistory, dateStr, maxLookback = 5) {
56
+ if (!priceHistory) return null;
57
+ let checkDate = new Date(dateStr + 'T00:00:00Z');
58
+ for (let i = 0; i < maxLookback; i++) {
59
+ const checkDateStr = checkDate.toISOString().slice(0, 10);
60
+ const price = priceHistory[checkDateStr];
61
+ if (price !== undefined && price !== null && price > 0) {
62
+ return price;
63
+ }
64
+ checkDate.setUTCDate(checkDate.getUTCDate() - 1);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * This is a 'meta' calculation. It runs once.
71
+ * @param {string} dateStr - The date string 'YYYY-MM-DD'.
72
+ * @param {object} dependencies - The shared dependencies (e.g., logger, calculationUtils).
73
+ * @param {object} config - The computation system configuration.
74
+ * @param {object} fetchedDependencies - (Unused)
75
+ * @returns {Promise<object>} The calculation result.
76
+ */
77
+ async process(dateStr, dependencies, config, fetchedDependencies) {
78
+ const { logger, calculationUtils, mappings } = dependencies;
79
+
80
+ // Load all price data and mappings
81
+ const priceMap = await loadAllPriceData();
82
+ const tickerMap = await calculationUtils.loadInstrumentMappings();
83
+
84
+ if (!priceMap || !tickerMap || !tickerMap.instrumentToTicker) {
85
+ logger.log('ERROR', '[instrument-price-momentum] Failed to load priceMap or mappings.');
86
+ return {};
87
+ }
88
+
89
+ const dateStrT20 = this._getDateStr(dateStr, -20);
90
+ const result = {};
91
+
92
+ for (const instrumentId in priceMap) {
93
+ const instrumentPrices = priceMap[instrumentId];
94
+ const ticker = tickerMap.instrumentToTicker[instrumentId];
95
+
96
+ if (!ticker) continue; // Skip if we can't map ID to ticker
97
+
98
+ const priceT = this._findPrice(instrumentPrices, dateStr);
99
+ const priceT20 = this._findPrice(instrumentPrices, dateStrT20);
100
+
101
+ let momentum = null;
102
+ if (priceT && priceT20 && priceT20 > 0) {
103
+ momentum = ((priceT - priceT20) / priceT20) * 100; // As percentage
104
+ }
105
+
106
+ result[ticker] = {
107
+ momentum_20d_pct: momentum
108
+ };
109
+ }
110
+
111
+ return result;
112
+ }
113
+ }
114
+
115
+ module.exports = InstrumentPriceMomentum;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 3) for skilled-unskilled divergence.
3
+ *
4
+ * This metric answers: "What divergence signals (e.g., capitulation,
5
+ * euphoria) can be found by comparing the net asset and sector flow
6
+ * of the 'Skilled Cohort' vs. the 'Unskilled Cohort'?"
7
+ *
8
+ * It *depends* on 'skilled-cohort-flow' and 'unskilled-cohort-flow'.
9
+ */
10
+ class SkilledUnskilledDivergence {
11
+ constructor() {
12
+ // No per-user processing
13
+ }
14
+
15
+ /**
16
+ * Defines the output schema for this calculation.
17
+ * @returns {object} JSON Schema object
18
+ */
19
+ static getSchema() {
20
+ const signalSchema = {
21
+ "type": "object",
22
+ "properties": {
23
+ "status": {
24
+ "type": "string",
25
+ "enum": ["Capitulation", "Euphoria", "Confirmation (Buy)", "Confirmation (Sell)", "Divergence (Skilled Buy)", "Divergence (Skilled Sell)", "Neutral"]
26
+ },
27
+ "skilled_flow_pct": { "type": "number" },
28
+ "unskilled_flow_pct": { "type": "number" }
29
+ },
30
+ "required": ["status", "skilled_flow_pct", "unskilled_flow_pct"]
31
+ };
32
+
33
+ return {
34
+ "type": "object",
35
+ "description": "Generates divergence signals by comparing net flow of 'Skilled' vs. 'Unskilled' cohorts, by asset and sector.",
36
+ "properties": {
37
+ "assets": {
38
+ "type": "object",
39
+ "description": "Divergence signals per asset.",
40
+ "patternProperties": { "^.*$": signalSchema }, // Ticker
41
+ "additionalProperties": signalSchema
42
+ },
43
+ "sectors": {
44
+ "type": "object",
45
+ "description": "Divergence signals per sector.",
46
+ "patternProperties": { "^.*$": signalSchema }, // Sector
47
+ "additionalProperties": signalSchema
48
+ }
49
+ },
50
+ "required": ["assets", "sectors"]
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Statically declare dependencies.
56
+ */
57
+ static getDependencies() {
58
+ return [
59
+ 'gem_skilled-cohort-flow', // Pass 2
60
+ 'gem_unskilled-cohort-flow' // Pass 2
61
+ ];
62
+ }
63
+
64
+ process() {
65
+ // No-op
66
+ }
67
+
68
+ _calculateDivergence(skilledFlow, unskilledFlow) {
69
+ const result = {};
70
+ if (!skilledFlow || !unskilledFlow) {
71
+ return result;
72
+ }
73
+
74
+ const allKeys = new Set([...Object.keys(skilledFlow), ...Object.keys(unskilledFlow)]);
75
+ const THRESHOLD = 1.0; // Min flow % to register as a signal
76
+
77
+ for (const key of allKeys) {
78
+ const sFlow = skilledFlow[key]?.net_flow_percentage || 0;
79
+ const dFlow = unskilledFlow[key]?.net_flow_percentage || 0;
80
+
81
+ let status = 'Neutral';
82
+
83
+ // Both buying
84
+ if (sFlow > THRESHOLD && dFlow > THRESHOLD) {
85
+ status = 'Confirmation (Buy)';
86
+ }
87
+ // Both selling
88
+ else if (sFlow < -THRESHOLD && dFlow < -THRESHOLD) {
89
+ status = 'Confirmation (Sell)';
90
+ }
91
+ // Skilled buying, Unskilled selling
92
+ else if (sFlow > THRESHOLD && dFlow < -THRESHOLD) {
93
+ status = 'Capitulation'; // Skilled buying the dip from unskilled
94
+ }
95
+ // Skilled selling, Unskilled buying
96
+ else if (sFlow < -THRESHOLD && dFlow > THRESHOLD) {
97
+ status = 'Euphoria'; // Skilled selling into unskilled fomo
98
+ }
99
+ // Skilled buying, Unskilled neutral
100
+ else if (sFlow > THRESHOLD && Math.abs(dFlow) < THRESHOLD) {
101
+ status = 'Divergence (Skilled Buy)';
102
+ }
103
+ // Skilled selling, Unskilled neutral
104
+ else if (sFlow < -THRESHOLD && Math.abs(dFlow) < THRESHOLD) {
105
+ status = 'Divergence (Skilled Sell)';
106
+ }
107
+
108
+ result[key] = {
109
+ status: status,
110
+ skilled_flow_pct: sFlow,
111
+ unskilled_flow_pct: dFlow
112
+ };
113
+ }
114
+ return result;
115
+ }
116
+
117
+ /**
118
+ * This is a 'meta' calculation.
119
+ * @param {object} fetchedDependencies - Results from Pass 2.
120
+ */
121
+ getResult(fetchedDependencies) {
122
+ const skilledFlowData = fetchedDependencies['skilled-cohort-flow'];
123
+ const unskilledFlowData = fetchedDependencies['unskilled-cohort-flow'];
124
+
125
+ const assetResult = this._calculateDivergence(skilledFlowData?.assets, unskilledFlowData?.assets);
126
+ const sectorResult = this._calculateDivergence(skilledFlowData?.sectors, unskilledFlowData?.sectors);
127
+
128
+ return {
129
+ assets: assetResult,
130
+ sectors: sectorResult
131
+ };
132
+ }
133
+
134
+ reset() {
135
+ // No state
136
+ }
137
+ }
138
+
139
+ module.exports = SkilledUnskilledDivergence;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 4) for the "Quant-Skill Alpha Signal".
3
+ *
4
+ * This metric synthesizes multiple Pass 1, 2, and 3 signals into a
5
+ * single, actionable "raw score" for each asset. It is designed to
6
+ * be the final, tradable signal from this computation branch.
7
+ *
8
+ * It weights:
9
+ * 1. Skilled/Unskilled Divergence (Pass 3)
10
+ * 2. Unskilled "FOMO" (from Cohort Momentum, Pass 3)
11
+ * 3. Platform vs. Sample Divergence (Pass 1)
12
+ * 4. Social Media Sentiment (Pass 1)
13
+ */
14
+ class QuantSkillAlphaSignal {
15
+ constructor() {
16
+ // Define the weights for the model.
17
+ // These would be optimized via backtesting.
18
+ this.W_DIVERGENCE = 0.40; // Skilled vs Unskilled
19
+ this.W_FOMO = 0.30; // Unskilled Momentum (faded)
20
+ this.W_PLATFORM = 0.15; // Sample vs Platform
21
+ this.W_SOCIAL = 0.15; // Social Sentiment
22
+ }
23
+
24
+ /**
25
+ * Defines the output schema for this calculation.
26
+ * @returns {object} JSON Schema object
27
+ */
28
+ static getSchema() {
29
+ const tickerSchema = {
30
+ "type": "object",
31
+ "properties": {
32
+ "raw_alpha_score": {
33
+ "type": "number",
34
+ "description": "The final weighted signal score. > 0 is bullish, < 0 is bearish."
35
+ },
36
+ "signal_status": {
37
+ "type": "string",
38
+ "description": "A human-readable signal (e.g., 'Strong Buy', 'Neutral')."
39
+ },
40
+ "component_divergence_score": { "type": "number" },
41
+ "component_unskilled_fomo_score": { "type": "number" },
42
+ "component_platform_divergence_score": { "type": "number" },
43
+ "component_social_sentiment_score": { "type": "number" }
44
+ },
45
+ "required": ["raw_alpha_score", "signal_status"]
46
+ };
47
+
48
+ return {
49
+ "type": "object",
50
+ "description": "A final, weighted alpha signal combining skill divergence, momentum, and sentiment.",
51
+ "patternProperties": {
52
+ "^.*$": tickerSchema // Ticker
53
+ },
54
+ "additionalProperties": tickerSchema
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Statically declare dependencies.
60
+ */
61
+ static getDependencies() {
62
+ return [
63
+ 'gem_skilled-unskilled-divergence', // Pass 3
64
+ 'gem_cohort-momentum-state', // Pass 3
65
+ 'gem_platform-conviction-divergence', // Pass 1
66
+ 'gem_social_sentiment_aggregation' // Pass 1
67
+ ];
68
+ }
69
+
70
+ process() {
71
+ // No-op
72
+ }
73
+
74
+ /**
75
+ * This is a 'meta' calculation.
76
+ * @param {object} fetchedDependencies - Results from previous passes.
77
+ */
78
+ getResult(fetchedDependencies) {
79
+ const divergenceData = fetchedDependencies['skilled-unskilled-divergence']?.assets;
80
+ const momentumData = fetchedDependencies['cohort-momentum-state'];
81
+ const platformData = fetchedDependencies['platform-conviction-divergence'];
82
+ const socialData = fetchedDependencies['social_sentiment_aggregation']?.per_ticker;
83
+
84
+ if (!divergenceData || !momentumData || !platformData || !socialData) {
85
+ return {}; // Missing one or more key dependencies
86
+ }
87
+
88
+ const result = {};
89
+ const allTickers = new Set([
90
+ ...Object.keys(divergenceData),
91
+ ...Object.keys(momentumData),
92
+ ...Object.keys(platformData),
93
+ ...Object.keys(socialData)
94
+ ]);
95
+
96
+ for (const ticker of allTickers) {
97
+ // 1. Get Divergence Signal (Buy = +1, Sell = -1)
98
+ const divStatus = divergenceData[ticker]?.status;
99
+ const divScore = divStatus === 'Capitulation' ? 1.0 : (divStatus === 'Euphoria' ? -1.0 : 0);
100
+
101
+ // 2. Get Unskilled FOMO Signal (We fade this, so we use -1)
102
+ // 'unskilled_momentum_score' is high positive when they buy a rally
103
+ const fomoScore = momentumData[ticker]?.unskilled_momentum_score || 0;
104
+
105
+ // 3. Get Platform Divergence Signal
106
+ // 'divergence' is positive when our sample is more bullish than the platform
107
+ const platformScore = platformData[ticker]?.divergence || 0;
108
+
109
+ // 4. Get Social Sentiment Signal
110
+ // 'net_sentiment_pct' is positive when social is bullish
111
+ const socialScore = socialData[ticker]?.net_sentiment_pct || 0;
112
+
113
+ // Normalize scores to a similar range (approx -100 to 100)
114
+ // (This is a simple normalization; a z-score would be more robust)
115
+ const s_div = divScore * 100.0;
116
+ const s_fomo = -1 * fomoScore; // Fading the signal
117
+ const s_plat = platformScore; // Already 0-100
118
+ const s_soc = socialScore; // Already 0-100
119
+
120
+ // Calculate final weighted score
121
+ const raw_alpha_score =
122
+ (s_div * this.W_DIVERGENCE) +
123
+ (s_fomo * this.W_FOMO) +
124
+ (s_plat * this.W_PLATFORM) +
125
+ (s_soc * this.W_SOCIAL);
126
+
127
+ // Determine human-readable status
128
+ let status = 'Neutral';
129
+ if (raw_alpha_score > 30) status = 'Strong Buy';
130
+ else if (raw_alpha_score > 10) status = 'Buy';
131
+ else if (raw_alpha_score < -30) status = 'Strong Sell';
132
+ else if (raw_alpha_score < -10) status = 'Sell';
133
+
134
+ result[ticker] = {
135
+ raw_alpha_score: raw_alpha_score,
136
+ signal_status: status,
137
+ component_divergence_score: s_div,
138
+ component_unskilled_fomo_score: s_fomo,
139
+ component_platform_divergence_score: s_plat,
140
+ component_social_sentiment_score: s_soc
141
+ };
142
+ }
143
+
144
+ return result;
145
+ }
146
+
147
+ reset() {
148
+ // No state
149
+ }
150
+ }
151
+
152
+ module.exports = QuantSkillAlphaSignal;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @fileoverview Calculation (Pass 1) for platform conviction divergence.
3
+ *
4
+ * This metric answers: "Is our 20,000-user sample more bullish or bearish
5
+ * on an asset than the entire eToro platform?"
6
+ *
7
+ * It compares the long/short ratio of our sample (from 'portfolio' data)
8
+ * against the platform-wide 'buy'/'sell' % (from 'insights' data).
9
+ */
10
+ const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
11
+
12
+ class PlatformConvictionDivergence {
13
+ constructor() {
14
+ // { [instrumentId]: { long: 0, short: 0 } }
15
+ this.sampledCounts = new Map();
16
+ this.mappings = null;
17
+ this.todayInsightsData = null;
18
+ }
19
+
20
+ /**
21
+ * Defines the output schema for this calculation.
22
+ * @returns {object} JSON Schema object
23
+ */
24
+ static getSchema() {
25
+ const tickerSchema = {
26
+ "type": "object",
27
+ "properties": {
28
+ "platform_long_pct": {
29
+ "type": "number",
30
+ "description": "Percentage of holders on the entire platform who are long."
31
+ },
32
+ "sampled_long_pct": {
33
+ "type": ["number", "null"],
34
+ "description": "Percentage of holders in our sample who are long. Null if no sample."
35
+ },
36
+ "divergence": {
37
+ "type": ["number", "null"],
38
+ "description": "The difference (Sampled % - Platform %). Positive means our sample is more bullish."
39
+ }
40
+ },
41
+ "required": ["platform_long_pct", "sampled_long_pct", "divergence"]
42
+ };
43
+
44
+ return {
45
+ "type": "object",
46
+ "description": "Calculates the divergence between the sample's long/short ratio and the platform's.",
47
+ "patternProperties": {
48
+ "^.*$": tickerSchema // Ticker
49
+ },
50
+ "additionalProperties": tickerSchema
51
+ };
52
+ }
53
+
54
+ /**
55
+ * This is a Pass 1 calculation and has no dependencies.
56
+ */
57
+ static getDependencies() {
58
+ return [];
59
+ }
60
+
61
+ _initAsset(instrumentId) {
62
+ if (!this.sampledCounts.has(instrumentId)) {
63
+ this.sampledCounts.set(instrumentId, { long: 0, short: 0 });
64
+ }
65
+ }
66
+
67
+ process(portfolioData, yesterdayPortfolio, userId, context, todayInsights) {
68
+ // Capture insights data on the first user processed
69
+ if (!this.todayInsightsData && todayInsights) {
70
+ this.todayInsightsData = todayInsights;
71
+ }
72
+
73
+ const positions = portfolioData.PublicPositions || portfolioData.AggregatedPositions;
74
+ if (!positions || !Array.isArray(positions)) {
75
+ return;
76
+ }
77
+
78
+ for (const pos of positions) {
79
+ const instrumentId = pos.InstrumentID;
80
+ if (!instrumentId) continue;
81
+
82
+ this._initAsset(instrumentId);
83
+ const assetData = this.sampledCounts.get(instrumentId);
84
+
85
+ if (pos.IsBuy) {
86
+ assetData.long++;
87
+ } else {
88
+ assetData.short++;
89
+ }
90
+ }
91
+ }
92
+
93
+ async getResult() {
94
+ if (!this.mappings) {
95
+ this.mappings = await loadInstrumentMappings();
96
+ }
97
+
98
+ const result = {};
99
+ const insights = this.todayInsightsData?.insights;
100
+
101
+ if (!insights || !Array.isArray(insights)) {
102
+ return {}; // No platform data to compare against
103
+ }
104
+
105
+ for (const instrument of insights) {
106
+ const instrumentId = instrument.instrumentId;
107
+ const ticker = this.mappings.instrumentToTicker[instrumentId];
108
+ if (!ticker) continue;
109
+
110
+ const platform_long_pct = instrument.buy || 0; // e.g., 51
111
+
112
+ const sampledData = this.sampledCounts.get(instrumentId);
113
+ const sampled_long = sampledData?.long || 0;
114
+ const sampled_short = sampledData?.short || 0;
115
+ const totalSampled = sampled_long + sampled_short;
116
+
117
+ let sampled_long_pct = null;
118
+ let divergence = null;
119
+
120
+ if (totalSampled > 0) {
121
+ sampled_long_pct = (sampled_long / totalSampled) * 100;
122
+ divergence = sampled_long_pct - platform_long_pct;
123
+ }
124
+
125
+ result[ticker] = {
126
+ platform_long_pct: platform_long_pct,
127
+ sampled_long_pct: sampled_long_pct,
128
+ divergence: divergence
129
+ };
130
+ }
131
+ return result;
132
+ }
133
+
134
+ reset() {
135
+ this.sampledCounts.clear();
136
+ this.mappings = null;
137
+ this.todayInsightsData = null;
138
+ }
139
+ }
140
+
141
+ module.exports = PlatformConvictionDivergence;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiden-shared-calculations-unified",
3
- "version": "1.0.72",
3
+ "version": "1.0.73",
4
4
  "description": "Shared calculation modules for the BullTrackers Computation System.",
5
5
  "main": "index.js",
6
6
  "files": [