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.
- package/calculations/behavioural/historical/asset_crowd_flow.js +2 -2
- package/calculations/behavioural/historical/gem_cohort-skill-definition.js +109 -0
- package/calculations/behavioural/historical/gem_skilled-cohort-flow.js +226 -0
- package/calculations/behavioural/historical/gem_unskilled-cohort-flow.js +225 -0
- package/calculations/meta/gem_cohort-momentum-state.js +110 -0
- package/calculations/meta/gem_instrument-price-momentum.js +115 -0
- package/calculations/meta/gem_skilled-unskilled-divergence.js +139 -0
- package/calculations/meta/quant-skill-alpha-signal.js +152 -0
- package/calculations/sentiment/gem_platform-conviction-divergence.js +141 -0
- package/package.json +1 -1
|
@@ -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?.
|
|
106
|
-
const tInvested = tP?.
|
|
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;
|