aiden-shared-calculations-unified 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ const { loadAllPriceData, getDailyPriceChange } = require('../../utils/price_data_provider');
2
+ const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
3
+
4
+ /**
5
+ * @fileoverview Calculates "Net Crowd Flow" for each asset.
6
+ *
7
+ * This isolates the change in an asset's average portfolio percentage
8
+ * that is *not* explained by the asset's own price movement.
9
+ *
10
+ * Net Crowd Flow = (Actual % Change) - (Expected % Change from Price Move)
11
+ *
12
+ * A positive value means the crowd actively bought (flowed into) the asset.
13
+ * A negative value means the crowd actively sold (flowed out of) the asset.
14
+ */
15
+ class AssetCrowdFlow {
16
+ constructor() {
17
+ this.asset_values = {}; // Stores { day1_value_sum: 0, day2_value_sum: 0 }
18
+ this.user_count = 0;
19
+ this.priceMap = null;
20
+ this.mappings = null;
21
+ this.dates = {}; // To store { today: '...', yesterday: '...' }
22
+ }
23
+
24
+ /**
25
+ * Helper to safely initialize an asset entry.
26
+ */
27
+ _initAsset(instrumentId) {
28
+ if (!this.asset_values[instrumentId]) {
29
+ this.asset_values[instrumentId] = {
30
+ day1_value_sum: 0,
31
+ day2_value_sum: 0
32
+ };
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Helper to sum the 'Value' field from an AggregatedPositions array.
38
+ */
39
+ _sumAssetValue(positions) {
40
+ const valueMap = {};
41
+ if (!positions || !Array.isArray(positions)) {
42
+ return valueMap;
43
+ }
44
+ for (const pos of positions) {
45
+ if (pos && pos.InstrumentID && pos.Value) {
46
+ valueMap[pos.InstrumentID] = (valueMap[pos.InstrumentID] || 0) + pos.Value;
47
+ }
48
+ }
49
+ return valueMap;
50
+ }
51
+
52
+ process(todayPortfolio, yesterdayPortfolio, userId, context) {
53
+ // This is a historical calculation, requires both days
54
+ if (!todayPortfolio || !yesterdayPortfolio || !todayPortfolio.AggregatedPositions || !yesterdayPortfolio.AggregatedPositions) {
55
+ return;
56
+ }
57
+
58
+ // Capture dates from context on the first run
59
+ if (!this.dates.today && context.todayDateStr && context.yesterdayDateStr) {
60
+ this.dates.today = context.todayDateStr;
61
+ this.dates.yesterday = context.yesterdayDateStr;
62
+ }
63
+
64
+ const yesterdayValues = this._sumAssetValue(yesterdayPortfolio.AggregatedPositions);
65
+ const todayValues = this._sumAssetValue(todayPortfolio.AggregatedPositions);
66
+
67
+ // Use a set of all unique instruments held across both days
68
+ const allInstrumentIds = new Set([
69
+ ...Object.keys(yesterdayValues),
70
+ ...Object.keys(todayValues)
71
+ ]);
72
+
73
+ for (const instrumentId of allInstrumentIds) {
74
+ this._initAsset(instrumentId);
75
+ this.asset_values[instrumentId].day1_value_sum += (yesterdayValues[instrumentId] || 0);
76
+ this.asset_values[instrumentId].day2_value_sum += (todayValues[instrumentId] || 0);
77
+ }
78
+
79
+ this.user_count++;
80
+ }
81
+
82
+ async getResult() {
83
+ if (this.user_count === 0 || !this.dates.today) {
84
+ return {}; // No users processed or dates not found
85
+ }
86
+
87
+ // Load dependencies (prices and mappings) in parallel
88
+ if (!this.priceMap || !this.mappings) {
89
+ const [priceData, mappingData] = await Promise.all([
90
+ loadAllPriceData(),
91
+ loadInstrumentMappings()
92
+ ]);
93
+ this.priceMap = priceData;
94
+ this.mappings = mappingData;
95
+ }
96
+
97
+ const finalResults = {};
98
+ const todayStr = this.dates.today;
99
+ const yesterdayStr = this.dates.yesterday;
100
+
101
+ for (const instrumentId in this.asset_values) {
102
+ const ticker = this.mappings.instrumentToTicker[instrumentId] || `id_${instrumentId}`;
103
+
104
+ // 1. Calculate average % values
105
+ const avg_day1_value = this.asset_values[instrumentId].day1_value_sum / this.user_count;
106
+ const avg_day2_value = this.asset_values[instrumentId].day2_value_sum / this.user_count;
107
+
108
+ // 2. Get the actual price change
109
+ const priceChangePct = getDailyPriceChange(instrumentId, yesterdayStr, todayStr, this.priceMap);
110
+
111
+ if (priceChangePct === null) {
112
+ // Cannot calculate if price data is missing for either day
113
+ finalResults[ticker] = {
114
+ net_crowd_flow_pct: 0,
115
+ error: "Missing price data for calculation."
116
+ };
117
+ continue;
118
+ }
119
+
120
+ // 3. Calculate the expected value (the "price-move" effect)
121
+ // We use avg_day1_value as the base. The cash flow proxy calculation
122
+ // uses (avg_value - avg_invested) because it's solving for a different unknown.
123
+ // Here, we are solving for flow *relative to the asset itself*.
124
+ const expected_day2_value = avg_day1_value * (1 + priceChangePct);
125
+
126
+ // 4. Find the signal (the "crowd-flow" effect)
127
+ const net_crowd_flow_pct = avg_day2_value - expected_day2_value;
128
+
129
+ finalResults[ticker] = {
130
+ net_crowd_flow_pct: net_crowd_flow_pct,
131
+ avg_value_day1_pct: avg_day1_value,
132
+ avg_value_day2_pct: avg_day2_value,
133
+ expected_value_day2_pct: expected_day2_value,
134
+ price_change_pct: priceChangePct,
135
+ user_sample_size: this.user_count
136
+ };
137
+ }
138
+
139
+ return finalResults;
140
+ }
141
+
142
+ reset() {
143
+ this.asset_values = {};
144
+ this.user_count = 0;
145
+ this.priceMap = null;
146
+ this.mappings = null;
147
+ this.dates = {};
148
+ }
149
+ }
150
+
151
+ module.exports = AssetCrowdFlow;
@@ -1,71 +1,118 @@
1
1
  /**
2
- * @fileoverview Estimates the average percentage deposited into accounts between two days.
2
+ * @fileoverview Estimates a proxy for net crowd cash flow (Deposits vs. Withdrawals)
3
+ * by aggregating portfolio percentage changes across all users.
4
+ *
5
+ * This calculation is based on the formula:
6
+ * Total_Change = P/L_Effect + Trading_Effect + Cash_Flow_Effect
7
+ *
8
+ * Where:
9
+ * - Total_Change = avg_value_day2 - avg_value_day1
10
+ * - P/L_Effect = avg_value_day1 - avg_invested_day1
11
+ * - Trading_Effect = avg_invested_day2 - avg_invested_day1
12
+ *
13
+ * We solve for Cash_Flow_Effect, which serves as our proxy.
14
+ * A negative value indicates a net DEPOSIT (denominator grew, shrinking all %s).
15
+ * A positive value indicates a net WITHDRAWAL (denominator shrank, inflating all %s).
3
16
  */
4
17
 
5
- class DepositPercentage {
18
+ class CrowdCashFlowProxy {
6
19
  constructor() {
7
- this.totalDepositPercentage = 0;
8
- this.userCount = 0;
20
+ this.total_invested_day1 = 0;
21
+ this.total_value_day1 = 0;
22
+ this.total_invested_day2 = 0;
23
+ this.total_value_day2 = 0;
24
+ this.user_count = 0;
9
25
  }
10
26
 
11
27
  /**
12
- * Calculates total PnL from aggregated or public positions.
13
- * @param {object} portfolio - Portfolio data object.
14
- * @returns {number|null} Total PnL or null if positions are missing.
28
+ * Helper to sum a specific field from an AggregatedPositions array.
29
+ * @param {Array<object>} positions - The AggregatedPositions array.
30
+ * @param {string} field - The field to sum ('Invested' or 'Value').
31
+ * @returns {number} The total sum of that field.
15
32
  */
16
- calculateTotalPnl(portfolio) {
17
- // Use the same logic as in ProfitabilityMigration
18
- if (portfolio && portfolio.AggregatedPositions) {
19
- return portfolio.AggregatedPositions.reduce((sum, pos) => sum + (pos.NetProfit || 0), 0);
20
- } else if (portfolio && portfolio.PublicPositions) {
21
- // PublicPositions might lack NetProfit, adjust if necessary based on your data
22
- return portfolio.PublicPositions.reduce((sum, pos) => sum + (pos.NetProfit || 0), 0);
33
+ _sumPositions(positions, field) {
34
+ if (!positions || !Array.isArray(positions)) {
35
+ return 0;
23
36
  }
24
- return null;
37
+ return positions.reduce((sum, pos) => sum + (pos[field] || 0), 0);
25
38
  }
26
39
 
27
40
  process(todayPortfolio, yesterdayPortfolio, userId) {
28
- if (!todayPortfolio || !yesterdayPortfolio || !yesterdayPortfolio.PortfolioValue || yesterdayPortfolio.PortfolioValue === 0) {
29
- // Need both portfolios and a non-zero yesterday value for calculation
41
+ // This calculation requires both days' data to compare
42
+ if (!todayPortfolio || !yesterdayPortfolio || !todayPortfolio.AggregatedPositions || !yesterdayPortfolio.AggregatedPositions) {
30
43
  return;
31
44
  }
32
45
 
33
- const valueChange = (todayPortfolio.PortfolioValue || 0) - yesterdayPortfolio.PortfolioValue;
46
+ const invested_day1 = this._sumPositions(yesterdayPortfolio.AggregatedPositions, 'Invested');
47
+ const value_day1 = this._sumPositions(yesterdayPortfolio.AggregatedPositions, 'Value');
48
+ const invested_day2 = this._sumPositions(todayPortfolio.AggregatedPositions, 'Invested');
49
+ const value_day2 = this._sumPositions(todayPortfolio.AggregatedPositions, 'Value');
34
50
 
35
- const todayPnl = this.calculateTotalPnl(todayPortfolio);
36
- const yesterdayPnl = this.calculateTotalPnl(yesterdayPortfolio);
37
-
38
- // If PnL can't be calculated for either day, we can't reliably estimate cash flow
39
- if (todayPnl === null || yesterdayPnl === null) {
51
+ // Only include users who have some form of positions
52
+ if (invested_day1 === 0 && invested_day2 === 0 && value_day1 === 0 && value_day2 === 0) {
40
53
  return;
41
54
  }
42
55
 
43
- const pnlChange = todayPnl - yesterdayPnl;
44
- const cashFlow = valueChange - pnlChange;
45
-
46
- // Check for deposit (positive cash flow)
47
- if (cashFlow > 0) {
48
- const depositPercentage = (cashFlow / yesterdayPortfolio.PortfolioValue) * 100;
49
- this.totalDepositPercentage += depositPercentage;
50
- this.userCount++;
51
- }
56
+ this.total_invested_day1 += invested_day1;
57
+ this.total_value_day1 += value_day1;
58
+ this.total_invested_day2 += invested_day2;
59
+ this.total_value_day2 += value_day2;
60
+ this.user_count++;
52
61
  }
53
62
 
54
63
  getResult() {
55
- if (this.userCount === 0) {
56
- return {}; // Return empty object if no users contributed
64
+ if (this.user_count === 0) {
65
+ return {}; // No users processed, return empty.
57
66
  }
58
67
 
68
+ // 1. Calculate the average portfolio for the crowd
69
+ const avg_invested_day1 = this.total_invested_day1 / this.user_count;
70
+ const avg_value_day1 = this.total_value_day1 / this.user_count;
71
+ const avg_invested_day2 = this.total_invested_day2 / this.user_count;
72
+ const avg_value_day2 = this.total_value_day2 / this.user_count;
73
+
74
+ // 2. Isolate the three effects
75
+ const total_value_change = avg_value_day2 - avg_value_day1;
76
+ const pl_effect = avg_value_day1 - avg_invested_day1;
77
+ const trading_effect = avg_invested_day2 - avg_invested_day1;
78
+
79
+ // 3. Solve for the Cash Flow Effect
80
+ // Total_Change = pl_effect + trading_effect + cash_flow_effect
81
+ const cash_flow_effect = total_value_change - pl_effect - trading_effect;
82
+
59
83
  return {
60
- // Calculate the final average directly
61
- average_deposit_percentage: this.totalDepositPercentage / this.userCount
84
+ // The final proxy value.
85
+ // A negative value signals a net DEPOSIT.
86
+ // A positive value signals a net WITHDRAWAL.
87
+ cash_flow_effect_proxy: cash_flow_effect,
88
+
89
+ // Interpretation for the frontend
90
+ interpretation: "A negative value signals a net crowd deposit. A positive value signals a net crowd withdrawal.",
91
+
92
+ // Debug components
93
+ components: {
94
+ total_value_change: total_value_change,
95
+ pl_effect: pl_effect,
96
+ trading_effect: trading_effect
97
+ },
98
+ // Debug averages
99
+ averages: {
100
+ avg_invested_day1: avg_invested_day1,
101
+ avg_value_day1: avg_value_day1,
102
+ avg_invested_day2: avg_invested_day2,
103
+ avg_value_day2: avg_value_day2
104
+ },
105
+ user_sample_size: this.user_count
62
106
  };
63
107
  }
64
108
 
65
109
  reset() {
66
- this.totalDepositPercentage = 0;
67
- this.userCount = 0;
110
+ this.total_invested_day1 = 0;
111
+ this.total_value_day1 = 0;
112
+ this.total_invested_day2 = 0;
113
+ this.total_value_day2 = 0;
114
+ this.user_count = 0;
68
115
  }
69
116
  }
70
117
 
71
- module.exports = DepositPercentage;
118
+ module.exports = CrowdCashFlowProxy;
package/index.js CHANGED
@@ -13,6 +13,7 @@ const path = require('path');
13
13
  // --- Utils (Manually Exported) ---
14
14
  const firestoreUtils = require('./utils/firestore_utils');
15
15
  const mappingProvider = require('./utils/sector_mapping_provider');
16
+ const priceProvider = require('./utils/price_data_provider'); // <-- ADD THIS
16
17
 
17
18
  // --- Calculations (Dynamically Loaded) ---
18
19
  const calculations = requireAll({
@@ -26,6 +27,7 @@ module.exports = {
26
27
  calculations,
27
28
  utils: {
28
29
  ...firestoreUtils,
29
- ...mappingProvider
30
+ ...mappingProvider,
31
+ ...priceProvider // <-- ADD THIS
30
32
  }
31
33
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiden-shared-calculations-unified",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Shared calculation modules for the BullTrackers Computation System.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -0,0 +1,103 @@
1
+ const { Firestore } = require('@google-cloud/firestore');
2
+ const firestore = new Firestore();
3
+
4
+ // Config
5
+ const PRICE_COLLECTION = 'asset_prices';
6
+ const CACHE_DURATION_MS = 3600000; // 1 hour
7
+
8
+ // Cache
9
+ let cache = {
10
+ timestamp: null,
11
+ priceMap: null, // Will be { instrumentId: { "YYYY-MM-DD": price, ... } }
12
+ };
13
+
14
+ // In-progress fetch promise
15
+ let fetchPromise = null;
16
+
17
+ /**
18
+ * Loads all sharded price data from the `asset_prices` collection.
19
+ * This is a heavy operation and should be cached.
20
+ */
21
+ async function loadAllPriceData() {
22
+ const now = Date.now();
23
+ if (cache.timestamp && (now - cache.timestamp < CACHE_DURATION_MS)) {
24
+ return cache.priceMap;
25
+ }
26
+
27
+ if (fetchPromise) {
28
+ return fetchPromise;
29
+ }
30
+
31
+ fetchPromise = (async () => {
32
+ console.log('Fetching and caching all asset price data...');
33
+ const masterPriceMap = {};
34
+
35
+ try {
36
+ const snapshot = await firestore.collection(PRICE_COLLECTION).get();
37
+
38
+ if (snapshot.empty) {
39
+ throw new Error(`Price collection '${PRICE_COLLECTION}' is empty.`);
40
+ }
41
+
42
+ // Loop through each shard document (e.g., "shard_0", "shard_1")
43
+ snapshot.forEach(doc => {
44
+ const shardData = doc.data();
45
+
46
+ // Loop through each instrumentId in the shard
47
+ for (const instrumentId in shardData) {
48
+ // Check if it's a valid instrument entry
49
+ if (shardData[instrumentId] && shardData[instrumentId].prices) {
50
+ masterPriceMap[instrumentId] = shardData[instrumentId].prices;
51
+ }
52
+ }
53
+ });
54
+
55
+ cache = {
56
+ timestamp: now,
57
+ priceMap: masterPriceMap,
58
+ };
59
+
60
+ console.log(`Successfully cached prices for ${Object.keys(masterPriceMap).length} instruments.`);
61
+ return masterPriceMap;
62
+
63
+ } catch (err) {
64
+ console.error('CRITICAL: Error loading price data:', err);
65
+ // On error, return an empty map but don't cache, so a future call can retry.
66
+ return {};
67
+ } finally {
68
+ // Clear the promise so the next call (if cache is stale) triggers a new fetch
69
+ fetchPromise = null;
70
+ }
71
+ })();
72
+
73
+ return fetchPromise;
74
+ }
75
+
76
+ /**
77
+ * A helper to safely get the price change percentage between two dates.
78
+ * @param {string} instrumentId - The instrument ID.
79
+ * @param {string} yesterdayStr - YYYY-MM-DD date string for yesterday.
80
+ * @param {string} todayStr - YYYY-MM-DD date string for today.
81
+ * @param {object} priceMap - The master price map from loadAllPriceData().
82
+ * @returns {number|null} The percentage change (e.g., 0.10 for +10%), or null if data is missing.
83
+ */
84
+ function getDailyPriceChange(instrumentId, yesterdayStr, todayStr, priceMap) {
85
+ if (!priceMap || !priceMap[instrumentId]) {
86
+ return null; // No price data for this instrument
87
+ }
88
+
89
+ const priceDay1 = priceMap[instrumentId][yesterdayStr];
90
+ const priceDay2 = priceMap[instrumentId][todayStr];
91
+
92
+ if (priceDay1 && priceDay2 && priceDay1 > 0) {
93
+ return (priceDay2 - priceDay1) / priceDay1;
94
+ }
95
+
96
+ return null; // Missing one or both dates, or division by zero
97
+ }
98
+
99
+
100
+ module.exports = {
101
+ loadAllPriceData,
102
+ getDailyPriceChange
103
+ };