aiden-shared-calculations-unified 1.0.68 → 1.0.69
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/{speculators → behavioural/historical}/holding_duration_per_asset.js +39 -34
- package/calculations/behavioural/overall_holding_duration.js +79 -0
- package/calculations/insights/daily_buy_sell_sentiment_count.js +36 -26
- package/calculations/insights/daily_ownership_per_sector.js +87 -0
- package/calculations/insights/daily_total_positions_held.js +30 -19
- package/calculations/insights/historical/daily_ownership_delta.js +46 -57
- package/calculations/pnl/overall_profitability_ratio.js +71 -0
- package/calculations/pnl/pnl_distribution_per_stock.js +93 -41
- package/calculations/pnl/profitability_ratio_per_sector,js +104 -0
- package/calculations/sectors/historical/diversification_pnl.js +64 -88
- package/calculations/speculators/historical/risk_appetite_change.js +60 -77
- package/package.json +1 -1
|
@@ -1,56 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Calculation (Pass 1) for P&L distribution.
|
|
2
|
+
* @fileoverview Calculation (Pass 1) for P&L distribution per stock.
|
|
3
3
|
*
|
|
4
|
-
* This metric
|
|
5
|
-
*
|
|
4
|
+
* This metric tracks the distribution of P&L percentages for all open
|
|
5
|
+
* positions, grouped by instrument.
|
|
6
6
|
*
|
|
7
|
-
* This
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* REFACTOR: This calculation now aggregates the distribution into
|
|
8
|
+
* predefined buckets on the server-side, returning a chart-ready
|
|
9
|
+
* histogram object instead of raw arrays.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
|
|
12
|
+
|
|
13
|
+
// Define the P&L percentage buckets for the histogram
|
|
14
|
+
const BUCKETS = [
|
|
15
|
+
{ label: 'loss_heavy', min: -Infinity, max: -50 }, // > 50% loss
|
|
16
|
+
{ label: 'loss_medium', min: -50, max: -25 }, // 25% to 50% loss
|
|
17
|
+
{ label: 'loss_light', min: -25, max: 0 }, // 0% to 25% loss
|
|
18
|
+
{ label: 'gain_light', min: 0, max: 25 }, // 0% to 25% gain
|
|
19
|
+
{ label: 'gain_medium', min: 25, max: 50 }, // 25% to 50% gain
|
|
20
|
+
{ label: 'gain_heavy', min: 50, max: 100 }, // 50% to 100% gain
|
|
21
|
+
{ label: 'gain_extreme', min: 100, max: Infinity } // > 100% gain
|
|
22
|
+
];
|
|
23
|
+
|
|
13
24
|
class PnlDistributionPerStock {
|
|
14
25
|
constructor() {
|
|
15
|
-
// We will store { [instrumentId]:
|
|
16
|
-
this.
|
|
26
|
+
// We will store { [instrumentId]: [pnlPercent1, pnlPercent2, ...] }
|
|
27
|
+
this.pnlMap = new Map();
|
|
28
|
+
this.mappings = null;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* Defines the output schema for this calculation.
|
|
33
|
+
* REFACTOR: Schema now describes the server-calculated histogram.
|
|
21
34
|
* @returns {object} JSON Schema object
|
|
22
35
|
*/
|
|
23
36
|
static getSchema() {
|
|
24
|
-
const
|
|
37
|
+
const bucketSchema = {
|
|
25
38
|
"type": "object",
|
|
26
|
-
"description": "
|
|
39
|
+
"description": "Histogram of P&L distribution for a single asset.",
|
|
27
40
|
"properties": {
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
"count": {
|
|
37
|
-
"type": "number",
|
|
38
|
-
"description": "Count of positions."
|
|
39
|
-
}
|
|
41
|
+
"loss_heavy": { "type": "number", "description": "Count of positions with > 50% loss" },
|
|
42
|
+
"loss_medium": { "type": "number", "description": "Count of positions with 25-50% loss" },
|
|
43
|
+
"loss_light": { "type": "number", "description": "Count of positions with 0-25% loss" },
|
|
44
|
+
"gain_light": { "type": "number", "description": "Count of positions with 0-25% gain" },
|
|
45
|
+
"gain_medium": { "type": "number", "description": "Count of positions with 25-50% gain" },
|
|
46
|
+
"gain_heavy": { "type": "number", "description": "Count of positions with 50-100% gain" },
|
|
47
|
+
"gain_extreme": { "type": "number", "description": "Count of positions with > 100% gain" },
|
|
48
|
+
"total_positions": { "type": "number", "description": "Total positions counted" }
|
|
40
49
|
},
|
|
41
|
-
"required": ["
|
|
50
|
+
"required": ["total_positions"]
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
return {
|
|
45
54
|
"type": "object",
|
|
46
|
-
"description": "
|
|
55
|
+
"description": "Calculates a histogram of P&L percentage distribution for all open positions, per asset.",
|
|
47
56
|
"patternProperties": {
|
|
48
|
-
"
|
|
57
|
+
"^.*$": bucketSchema // Ticker
|
|
49
58
|
},
|
|
50
|
-
"additionalProperties":
|
|
59
|
+
"additionalProperties": bucketSchema
|
|
51
60
|
};
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
_initAsset(instrumentId) {
|
|
64
|
+
if (!this.pnlMap.has(instrumentId)) {
|
|
65
|
+
this.pnlMap.set(instrumentId, []);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
process(portfolioData) {
|
|
55
70
|
const positions = portfolioData.PublicPositions || portfolioData.AggregatedPositions;
|
|
56
71
|
if (!positions || !Array.isArray(positions)) {
|
|
@@ -59,27 +74,64 @@ class PnlDistributionPerStock {
|
|
|
59
74
|
|
|
60
75
|
for (const pos of positions) {
|
|
61
76
|
const instrumentId = pos.InstrumentID;
|
|
62
|
-
|
|
77
|
+
const pnlPercent = pos.ProfitRate; // ProfitRate is P&L % (e.g., 0.5 = +50%)
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
// Ensure we have valid data
|
|
80
|
+
if (!instrumentId || typeof pnlPercent !== 'number') {
|
|
81
|
+
continue;
|
|
66
82
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.
|
|
71
|
-
this.assets[instrumentId].sumSq += (pnl * pnl);
|
|
72
|
-
this.assets[instrumentId].count++;
|
|
83
|
+
|
|
84
|
+
this._initAsset(instrumentId);
|
|
85
|
+
// Convert ProfitRate (0.5) to percentage (50)
|
|
86
|
+
this.pnlMap.get(instrumentId).push(pnlPercent * 100);
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
90
|
+
/**
|
|
91
|
+
* REFACTOR: This method now calculates the distribution on the server.
|
|
92
|
+
* It transforms the raw P&L arrays into histogram bucket counts.
|
|
93
|
+
*/
|
|
94
|
+
async getResult() {
|
|
95
|
+
if (!this.mappings) {
|
|
96
|
+
this.mappings = await loadInstrumentMappings();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = {};
|
|
100
|
+
|
|
101
|
+
for (const [instrumentId, pnlValues] of this.pnlMap.entries()) {
|
|
102
|
+
const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
|
|
103
|
+
|
|
104
|
+
// 1. Initialize the histogram object for this ticker
|
|
105
|
+
const histogram = {
|
|
106
|
+
loss_heavy: 0,
|
|
107
|
+
loss_medium: 0,
|
|
108
|
+
loss_light: 0,
|
|
109
|
+
gain_light: 0,
|
|
110
|
+
gain_medium: 0,
|
|
111
|
+
gain_heavy: 0,
|
|
112
|
+
gain_extreme: 0,
|
|
113
|
+
total_positions: pnlValues.length
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// 2. Process all P&L values into the buckets
|
|
117
|
+
for (const pnl of pnlValues) {
|
|
118
|
+
for (const bucket of BUCKETS) {
|
|
119
|
+
if (pnl >= bucket.min && pnl < bucket.max) {
|
|
120
|
+
histogram[bucket.label]++;
|
|
121
|
+
break; // Move to the next P&L value
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Add the aggregated histogram to the final result
|
|
127
|
+
result[ticker] = histogram;
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
79
130
|
}
|
|
80
131
|
|
|
81
132
|
reset() {
|
|
82
|
-
this.
|
|
133
|
+
this.pnlMap.clear();
|
|
134
|
+
this.mappings = null;
|
|
83
135
|
}
|
|
84
136
|
}
|
|
85
137
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Calculation (Pass 1) for P&L.
|
|
3
|
+
*
|
|
4
|
+
* This metric provides the profitability ratio (profitable positions /
|
|
5
|
+
* unprofitable positions) for all open positions, grouped by sector.
|
|
6
|
+
*/
|
|
7
|
+
const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
|
|
8
|
+
|
|
9
|
+
class ProfitabilityRatioPerSector {
|
|
10
|
+
constructor() {
|
|
11
|
+
// We will store { [sectorName]: { profitable: 0, unprofitable: 0 } }
|
|
12
|
+
this.sectorMap = new Map();
|
|
13
|
+
this.mappings = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defines the output schema for this calculation.
|
|
18
|
+
* @returns {object} JSON Schema object
|
|
19
|
+
*/
|
|
20
|
+
static getSchema() {
|
|
21
|
+
const sectorSchema = {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"ratio": {
|
|
25
|
+
"type": ["number", "null"],
|
|
26
|
+
"description": "Ratio of profitable to unprofitable positions (profitable / unprofitable). Null if 0 unprofitable."
|
|
27
|
+
},
|
|
28
|
+
"profitable_count": { "type": "number" },
|
|
29
|
+
"unprofitable_count": { "type": "number" }
|
|
30
|
+
},
|
|
31
|
+
"required": ["ratio", "profitable_count", "unprofitable_count"]
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"description": "Calculates the profitability ratio (profitable/unprofitable positions) per sector.",
|
|
37
|
+
"patternProperties": {
|
|
38
|
+
"^.*$": sectorSchema // Sector Name
|
|
39
|
+
},
|
|
40
|
+
"additionalProperties": sectorSchema
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_initSector(sectorName) {
|
|
45
|
+
if (!this.sectorMap.has(sectorName)) {
|
|
46
|
+
this.sectorMap.set(sectorName, { profitable: 0, unprofitable: 0 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async process(portfolioData) {
|
|
51
|
+
if (!this.mappings) {
|
|
52
|
+
// Load mappings on first process call
|
|
53
|
+
this.mappings = await loadInstrumentMappings();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const positions = portfolioData.PublicPositions || portfolioData.AggregatedPositions;
|
|
57
|
+
if (!positions || !Array.isArray(positions)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const pos of positions) {
|
|
62
|
+
const instrumentId = pos.InstrumentID;
|
|
63
|
+
const pnl = pos.NetProfit;
|
|
64
|
+
|
|
65
|
+
if (!instrumentId || typeof pnl !== 'number') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Find sector name
|
|
70
|
+
const sectorName = this.mappings.instrumentToSectorName[instrumentId] || 'N/A';
|
|
71
|
+
this._initSector(sectorName);
|
|
72
|
+
const data = this.sectorMap.get(sectorName);
|
|
73
|
+
|
|
74
|
+
// Tally
|
|
75
|
+
if (pnl > 0) {
|
|
76
|
+
data.profitable++;
|
|
77
|
+
} else if (pnl < 0) {
|
|
78
|
+
data.unprofitable++;
|
|
79
|
+
}
|
|
80
|
+
// Note: pnl === 0 is neutral and not counted in the ratio
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getResult() {
|
|
85
|
+
const result = {};
|
|
86
|
+
for (const [sectorName, data] of this.sectorMap.entries()) {
|
|
87
|
+
const { profitable, unprofitable } = data;
|
|
88
|
+
|
|
89
|
+
result[sectorName] = {
|
|
90
|
+
ratio: (unprofitable > 0) ? (profitable / unprofitable) : null,
|
|
91
|
+
profitable_count: profitable,
|
|
92
|
+
unprofitable_count: unprofitable
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reset() {
|
|
99
|
+
this.sectorMap.clear();
|
|
100
|
+
// Do not reset mappings, it's expensive to load
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = ProfitabilityRatioPerSector;
|
|
@@ -1,138 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Calculation (Pass
|
|
2
|
+
* @fileoverview Calculation (Pass 1) for Diversification vs. P&L.
|
|
3
3
|
*
|
|
4
|
-
* This metric answers: "
|
|
5
|
-
*
|
|
4
|
+
* This metric answers: "Is there a correlation between the number
|
|
5
|
+
* of sectors a user is invested in and their average P&L?"
|
|
6
|
+
*
|
|
7
|
+
* REFACTOR: This calculation now aggregates the results on the server,
|
|
8
|
+
* returning the average P&L per score, not raw data arrays.
|
|
6
9
|
*/
|
|
7
|
-
const { loadInstrumentMappings } = require('../../../utils/sector_mapping_provider');
|
|
8
|
-
|
|
9
10
|
class DiversificationPnl {
|
|
10
11
|
constructor() {
|
|
11
|
-
// { [
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
'2-3': { pnl_sum: 0, user_count: 0 },
|
|
15
|
-
'4-5': { pnl_sum: 0, user_count: 0 },
|
|
16
|
-
'6-10': { pnl_sum: 0, user_count: 0 },
|
|
17
|
-
'11+': { pnl_sum: 0, user_count: 0 },
|
|
18
|
-
};
|
|
19
|
-
this.mappings = null;
|
|
12
|
+
// Stores { [score]: { pnlSum: 0, count: 0 } }
|
|
13
|
+
// The 'score' is the number of unique sectors
|
|
14
|
+
this.pnlByScore = new Map();
|
|
20
15
|
}
|
|
21
16
|
|
|
22
17
|
/**
|
|
23
18
|
* Defines the output schema for this calculation.
|
|
19
|
+
* REFACTOR: Schema now describes the final aggregated result.
|
|
24
20
|
* @returns {object} JSON Schema object
|
|
25
21
|
*/
|
|
26
22
|
static getSchema() {
|
|
27
|
-
const
|
|
23
|
+
const scoreSchema = {
|
|
28
24
|
"type": "object",
|
|
29
|
-
"description": "Aggregated P&L
|
|
25
|
+
"description": "Aggregated P&L data for a specific diversification score.",
|
|
30
26
|
"properties": {
|
|
31
|
-
"
|
|
27
|
+
"average_pnl": {
|
|
32
28
|
"type": "number",
|
|
33
|
-
"description": "The average
|
|
29
|
+
"description": "The average P&L (in USD) for all users with this score."
|
|
34
30
|
},
|
|
35
31
|
"user_count": {
|
|
36
32
|
"type": "number",
|
|
37
|
-
"description": "The number of users
|
|
38
|
-
},
|
|
39
|
-
"pnl_sum": {
|
|
40
|
-
"type": "number",
|
|
41
|
-
"description": "The sum of all P&L for users in this bucket."
|
|
33
|
+
"description": "The number of users who had this diversification score."
|
|
42
34
|
}
|
|
43
35
|
},
|
|
44
|
-
"required": ["
|
|
36
|
+
"required": ["average_pnl", "user_count"]
|
|
45
37
|
};
|
|
46
|
-
|
|
38
|
+
|
|
47
39
|
return {
|
|
48
40
|
"type": "object",
|
|
49
|
-
"description": "
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"2-3": bucketSchema,
|
|
53
|
-
"4-5": bucketSchema,
|
|
54
|
-
"6-10": bucketSchema,
|
|
55
|
-
"11+": bucketSchema
|
|
41
|
+
"description": "Calculates the average P&L correlated by diversification score (number of unique sectors).",
|
|
42
|
+
"patternProperties": {
|
|
43
|
+
"^[0-9]+$": scoreSchema // Key is the score (e.g., "1", "2")
|
|
56
44
|
},
|
|
57
|
-
"
|
|
45
|
+
"additionalProperties": scoreSchema
|
|
58
46
|
};
|
|
59
47
|
}
|
|
60
48
|
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (count >= 6 && count <= 10) return '6-10';
|
|
66
|
-
if (count >= 11) return '11+';
|
|
67
|
-
return null;
|
|
49
|
+
_initScore(score) {
|
|
50
|
+
if (!this.pnlByScore.has(score)) {
|
|
51
|
+
this.pnlByScore.set(score, { pnlSum: 0, count: 0 });
|
|
52
|
+
}
|
|
68
53
|
}
|
|
69
54
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} todayPortfolio
|
|
57
|
+
* @param {object} yesterdayPortfolio
|
|
58
|
+
*/
|
|
59
|
+
process(todayPortfolio, yesterdayPortfolio) {
|
|
60
|
+
const tPositions = todayPortfolio.PublicPositions || todayPortfolio.AggregatedPositions;
|
|
61
|
+
if (!tPositions || !Array.isArray(tPositions) || tPositions.length === 0) {
|
|
73
62
|
return;
|
|
74
63
|
}
|
|
75
|
-
|
|
76
|
-
if (!this.mappings) {
|
|
77
|
-
// Context contains the mappings loaded in Pass 1
|
|
78
|
-
this.mappings = context.mappings;
|
|
79
|
-
}
|
|
80
64
|
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const instrumentId = pos.InstrumentID;
|
|
90
|
-
if (instrumentId) {
|
|
91
|
-
const sector = this.mappings.instrumentToSector[instrumentId] || 'Other';
|
|
92
|
-
uniqueSectors.add(sector);
|
|
65
|
+
const yPortfolio = yesterdayPortfolio.Portfolio;
|
|
66
|
+
if (!yPortfolio) return;
|
|
67
|
+
|
|
68
|
+
// 1. Calculate diversification score (count of unique sectors)
|
|
69
|
+
const sectors = new Set();
|
|
70
|
+
for (const pos of tPositions) {
|
|
71
|
+
if (pos.SectorID) {
|
|
72
|
+
sectors.add(pos.SectorID);
|
|
93
73
|
}
|
|
94
74
|
}
|
|
95
|
-
|
|
96
|
-
const sectorCount = uniqueSectors.size;
|
|
97
|
-
if (sectorCount === 0) {
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
75
|
+
const diversificationScore = sectors.size;
|
|
100
76
|
|
|
101
|
-
|
|
102
|
-
if (
|
|
77
|
+
// Skip users with 0 positions/sectors
|
|
78
|
+
if (diversificationScore === 0) {
|
|
103
79
|
return;
|
|
104
80
|
}
|
|
105
|
-
|
|
106
|
-
// Use the P&L from the summary, which is for the *day*
|
|
107
|
-
const dailyPnl = todayPortfolio.Summary?.NetProfit || 0;
|
|
108
81
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
82
|
+
// 2. Get total P&L from yesterday's snapshot
|
|
83
|
+
const pnl = yPortfolio.Equity - yPortfolio.EquityBase; // Total P&L in USD
|
|
84
|
+
|
|
85
|
+
// 3. Store P&L sum and count by score
|
|
86
|
+
this._initScore(diversificationScore);
|
|
87
|
+
const data = this.pnlByScore.get(diversificationScore);
|
|
88
|
+
data.pnlSum += pnl;
|
|
89
|
+
data.count++;
|
|
112
90
|
}
|
|
113
91
|
|
|
92
|
+
/**
|
|
93
|
+
* REFACTOR: This method now calculates the final average P&L per score.
|
|
94
|
+
*/
|
|
114
95
|
getResult() {
|
|
115
96
|
const result = {};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
97
|
+
|
|
98
|
+
for (const [score, data] of this.pnlByScore.entries()) {
|
|
99
|
+
if (data.count > 0) {
|
|
100
|
+
result[score] = {
|
|
101
|
+
average_pnl: data.pnlSum / data.count,
|
|
102
|
+
user_count: data.count
|
|
103
|
+
};
|
|
104
|
+
}
|
|
123
105
|
}
|
|
106
|
+
|
|
124
107
|
return result;
|
|
125
108
|
}
|
|
126
109
|
|
|
127
110
|
reset() {
|
|
128
|
-
this.
|
|
129
|
-
'1': { pnl_sum: 0, user_count: 0 },
|
|
130
|
-
'2-3': { pnl_sum: 0, user_count: 0 },
|
|
131
|
-
'4-5': { pnl_sum: 0, user_count: 0 },
|
|
132
|
-
'6-10': { pnl_sum: 0, user_count: 0 },
|
|
133
|
-
'11+': { pnl_sum: 0, user_count: 0 },
|
|
134
|
-
};
|
|
135
|
-
this.mappings = null;
|
|
111
|
+
this.pnlByScore.clear();
|
|
136
112
|
}
|
|
137
113
|
}
|
|
138
114
|
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Calculation (Pass
|
|
2
|
+
* @fileoverview Calculation (Pass 1) for speculator metric.
|
|
3
3
|
*
|
|
4
|
-
* This metric
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* This is a *stateful* calculation that computes a 30-day
|
|
9
|
-
* rolling average.
|
|
4
|
+
* This metric tracks the change in a speculator's risk appetite by
|
|
5
|
+
* comparing the "risk" of their portfolio today vs. yesterday.
|
|
6
|
+
* Risk is defined as (TotalLeveragedInvestment / TotalInvestment).
|
|
10
7
|
*/
|
|
8
|
+
|
|
11
9
|
class RiskAppetiteChange {
|
|
12
10
|
constructor() {
|
|
13
|
-
//
|
|
14
|
-
this.
|
|
15
|
-
// Stores *yesterday's* 30-day history
|
|
16
|
-
this.history = [];
|
|
11
|
+
// We will store { [userId]: { risk_appetite_change: 0 } }
|
|
12
|
+
this.userRiskMap = new Map();
|
|
17
13
|
}
|
|
18
14
|
|
|
19
15
|
/**
|
|
@@ -23,89 +19,76 @@ class RiskAppetiteChange {
|
|
|
23
19
|
static getSchema() {
|
|
24
20
|
return {
|
|
25
21
|
"type": "object",
|
|
26
|
-
"description": "Tracks the
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"type": "
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"type": "number",
|
|
38
|
-
"description": "30-day rolling average of the SL distance."
|
|
39
|
-
},
|
|
40
|
-
"history_30d": {
|
|
41
|
-
"type": "array",
|
|
42
|
-
"description": "30-day history of daily average SL distance.",
|
|
43
|
-
"items": { "type": "number" }
|
|
22
|
+
"description": "Tracks the daily change in risk appetite (leveraged investment / total investment) for each speculator.",
|
|
23
|
+
"patternProperties": {
|
|
24
|
+
"^[a-zA-Z0-9_]+$": { // Matches any user ID
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"risk_appetite_change": {
|
|
28
|
+
"type": "number",
|
|
29
|
+
"description": "The percentage point change in risk appetite from yesterday to today."
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"required": ["risk_appetite_change"]
|
|
44
33
|
}
|
|
45
34
|
},
|
|
46
|
-
"
|
|
35
|
+
"additionalProperties": false
|
|
47
36
|
};
|
|
48
37
|
}
|
|
49
38
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Helper to calculate the risk score from a portfolio snapshot.
|
|
41
|
+
* @param {object} portfolio - The portfolio snapshot.
|
|
42
|
+
* @returns {number} The risk score (0-100).
|
|
43
|
+
*/
|
|
44
|
+
_calculateRisk(portfolio) {
|
|
45
|
+
const totalInvestment = portfolio?.TotalInvestment;
|
|
46
|
+
const totalLeveragedInvestment = portfolio?.TotalLeveragedInvestment;
|
|
47
|
+
|
|
48
|
+
if (!totalInvestment || totalInvestment === 0 || !totalLeveragedInvestment) {
|
|
49
|
+
return 0;
|
|
57
50
|
}
|
|
58
51
|
|
|
59
|
-
|
|
52
|
+
// Return as a percentage
|
|
53
|
+
return (totalLeveragedInvestment / totalInvestment) * 100;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} todayPortfolio
|
|
58
|
+
* @param {object} yesterdayPortfolio
|
|
59
|
+
* @param {string} userId
|
|
60
|
+
*/
|
|
61
|
+
process(todayPortfolio, yesterdayPortfolio, userId) {
|
|
62
|
+
// --- FIX ---
|
|
63
|
+
// Add null check to prevent crash on the first day of processing
|
|
64
|
+
if (!todayPortfolio || !yesterExamplePortfolio || !todayPortfolio.Portfolio || !yesterdayPortfolio.Portfolio) {
|
|
60
65
|
return;
|
|
61
66
|
}
|
|
67
|
+
// --- END FIX ---
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
69
|
+
// 1. Get portfolio snapshots
|
|
70
|
+
const yPortfolio = yesterdayPortfolio.Portfolio;
|
|
71
|
+
const tPortfolio = todayPortfolio.Portfolio;
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
73
|
+
// 2. Calculate risk for both days
|
|
74
|
+
const yRisk = this._calculateRisk(yPortfolio);
|
|
75
|
+
const tRisk = this._calculateRisk(tPortfolio);
|
|
76
|
+
|
|
77
|
+
// 3. Calculate the change (in percentage points)
|
|
78
|
+
const change = tRisk - yRisk;
|
|
79
|
+
|
|
80
|
+
this.userRiskMap.set(userId, {
|
|
81
|
+
risk_appetite_change: change
|
|
82
|
+
});
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
getResult() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
let today_avg_dist = 0;
|
|
84
|
-
if (this.sl_distances.length > 0) {
|
|
85
|
-
today_avg_dist = (this.sl_distances.reduce((a, b) => a + b, 0) / this.sl_distances.length) * 100;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const newHistory = [today_avg_dist, ...yHistory].slice(0, 30);
|
|
89
|
-
|
|
90
|
-
const yesterday_avg = yHistory[0] || 0;
|
|
91
|
-
const avg_30d = newHistory.reduce((a, b) => a + b, 0) / newHistory.length;
|
|
92
|
-
|
|
93
|
-
let daily_change = 0;
|
|
94
|
-
if (yesterday_avg > 0) {
|
|
95
|
-
daily_change = ((today_avg_dist - yesterday_avg) / yesterday_avg) * 100;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
risk_appetite_score: today_avg_dist, // Today's score
|
|
100
|
-
daily_change_pct: daily_change,
|
|
101
|
-
avg_sl_distance_30d: avg_30d,
|
|
102
|
-
history_30d: newHistory
|
|
103
|
-
};
|
|
86
|
+
// Convert Map to plain object for Firestore
|
|
87
|
+
return Object.fromEntries(this.userRiskMap);
|
|
104
88
|
}
|
|
105
89
|
|
|
106
90
|
reset() {
|
|
107
|
-
this.
|
|
108
|
-
this.history = [];
|
|
91
|
+
this.userRiskMap.clear();
|
|
109
92
|
}
|
|
110
93
|
}
|
|
111
94
|
|