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.
@@ -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 answers: "What are the sum, sum of squares, and
5
- * count of P&L for each stock?"
4
+ * This metric tracks the distribution of P&L percentages for all open
5
+ * positions, grouped by instrument.
6
6
  *
7
- * This is a foundational calculation used by other metrics
8
- * (like 'crowd_sharpe_ratio_proxy') to calculate variance
9
- * and standard deviation.
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
- // No mappings needed here, as the consumer (e.g., Sharpe Ratio)
12
- // will handle the mapping. This class just aggregates by ID.
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]: { sum: 0, sumSq: 0, count: 0 } }
16
- this.assets = {};
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 distSchema = {
37
+ const bucketSchema = {
25
38
  "type": "object",
26
- "description": "Raw statistical components for P&L on a single asset.",
39
+ "description": "Histogram of P&L distribution for a single asset.",
27
40
  "properties": {
28
- "sum": {
29
- "type": "number",
30
- "description": "Sum of P&L values."
31
- },
32
- "sumSq": {
33
- "type": "number",
34
- "description": "Sum of squared P&L values (for variance calculation)."
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": ["sum", "sumSq", "count"]
50
+ "required": ["total_positions"]
42
51
  };
43
52
 
44
53
  return {
45
54
  "type": "object",
46
- "description": "Collects P&L distribution components (sum, sumSq, count) per asset, keyed by InstrumentID.",
55
+ "description": "Calculates a histogram of P&L percentage distribution for all open positions, per asset.",
47
56
  "patternProperties": {
48
- "^[0-9]+$": distSchema // InstrumentID (numeric string)
57
+ "^.*$": bucketSchema // Ticker
49
58
  },
50
- "additionalProperties": distSchema
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
- if (!instrumentId) continue;
77
+ const pnlPercent = pos.ProfitRate; // ProfitRate is P&L % (e.g., 0.5 = +50%)
63
78
 
64
- if (!this.assets[instrumentId]) {
65
- this.assets[instrumentId] = { sum: 0, sumSq: 0, count: 0 };
79
+ // Ensure we have valid data
80
+ if (!instrumentId || typeof pnlPercent !== 'number') {
81
+ continue;
66
82
  }
67
-
68
- const pnl = pos.NetProfit || 0;
69
-
70
- this.assets[instrumentId].sum += pnl;
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
- getResult() {
77
- // Return the raw aggregated data, keyed by instrumentId
78
- return this.assets;
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.assets = {};
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) for P&L by diversification.
2
+ * @fileoverview Calculation (Pass 1) for Diversification vs. P&L.
3
3
  *
4
- * This metric answers: "What is the average daily P&L for users,
5
- * bucketed by the number of *unique sectors* they are invested in?"
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
- // { [bucket]: { pnl_sum: 0, user_count: 0 } }
12
- this.buckets = {
13
- '1': { pnl_sum: 0, user_count: 0 },
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 bucketSchema = {
23
+ const scoreSchema = {
28
24
  "type": "object",
29
- "description": "Aggregated P&L metrics for a sector diversification bucket.",
25
+ "description": "Aggregated P&L data for a specific diversification score.",
30
26
  "properties": {
31
- "average_daily_pnl": {
27
+ "average_pnl": {
32
28
  "type": "number",
33
- "description": "The average daily P&L for users in this bucket."
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 in this bucket."
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": ["average_daily_pnl", "user_count", "pnl_sum"]
36
+ "required": ["average_pnl", "user_count"]
45
37
  };
46
-
38
+
47
39
  return {
48
40
  "type": "object",
49
- "description": "Average daily P&L bucketed by the number of unique sectors a user is invested in.",
50
- "properties": {
51
- "1": bucketSchema,
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
- "required": ["1", "2-3", "4-5", "6-10", "11+"]
45
+ "additionalProperties": scoreSchema
58
46
  };
59
47
  }
60
48
 
61
- _getBucket(count) {
62
- if (count === 1) return '1';
63
- if (count >= 2 && count <= 3) return '2-3';
64
- if (count >= 4 && count <= 5) return '4-5';
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
- process(todayPortfolio, yesterdayPortfolio, userId, context) {
71
- // This calculation only needs today's portfolio state
72
- if (!todayPortfolio) {
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 positions = todayPortfolio.AggregatedPositions || todayPortfolio.PublicPositions;
82
- if (!positions || !Array.isArray(positions) || !this.mappings) {
83
- return;
84
- }
85
-
86
- // Find unique sectors for this user
87
- const uniqueSectors = new Set();
88
- for (const pos of positions) {
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
- const bucketKey = this._getBucket(sectorCount);
102
- if (!bucketKey) {
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
- const bucket = this.buckets[bucketKey];
110
- bucket.pnl_sum += dailyPnl;
111
- bucket.user_count++;
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
- for (const key in this.buckets) {
117
- const bucket = this.buckets[key];
118
- result[key] = {
119
- average_daily_pnl: (bucket.user_count > 0) ? (bucket.pnl_sum / bucket.user_count) : 0,
120
- user_count: bucket.user_count,
121
- pnl_sum: bucket.pnl_sum
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.buckets = {
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) for speculator metric.
2
+ * @fileoverview Calculation (Pass 1) for speculator metric.
3
3
  *
4
- * This metric answers: "What is the daily change in
5
- * speculators' risk appetite, as measured by the
6
- * average distance of their stop-loss orders?"
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
- // Stores *today's* raw values
14
- this.sl_distances = [];
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 30-day rolling average of speculator stop loss distance as a proxy for risk appetite.",
27
- "properties": {
28
- "risk_appetite_score": {
29
- "type": "number",
30
- "description": "Today's average SL distance %."
31
- },
32
- "daily_change_pct": {
33
- "type": "number",
34
- "description": "Percentage change from yesterday's average."
35
- },
36
- "avg_sl_distance_30d": {
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
- "required": ["risk_appetite_score", "daily_change_pct", "avg_sl_distance_30d", "history_30d"]
35
+ "additionalProperties": false
47
36
  };
48
37
  }
49
38
 
50
- process(todayPortfolio, yesterdayPortfolio, userId, context) {
51
- // 1. Get this metric's history from yesterday (pre-loaded)
52
- if (this.history.length === 0) { // Only run once
53
- const yHistoryData = context.yesterdaysDependencyData['risk_appetite_change'];
54
- if (yHistoryData) {
55
- this.history = yHistoryData.history_30d || [];
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
- if (todayPortfolio?.context?.userType !== 'speculator') {
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
- const positions = todayPortfolio.PublicPositions;
64
- if (!positions || !Array.isArray(positions)) {
65
- return;
66
- }
69
+ // 1. Get portfolio snapshots
70
+ const yPortfolio = yesterdayPortfolio.Portfolio;
71
+ const tPortfolio = todayPortfolio.Portfolio;
67
72
 
68
- for (const pos of positions) {
69
- const sl_rate = pos.StopLossRate || 0;
70
- const open_rate = pos.OpenRate || 0;
71
-
72
- if (sl_rate > 0 && open_rate > 0) {
73
- const distance = Math.abs(open_rate - sl_rate);
74
- const distance_pct = (distance / open_rate);
75
- this.sl_distances.push(distance_pct);
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
- const yHistory = this.history;
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.sl_distances = [];
108
- this.history = [];
91
+ this.userRiskMap.clear();
109
92
  }
110
93
  }
111
94