aiden-shared-calculations-unified 1.0.11 → 1.0.13
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;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const { FieldValue } = require('@google-cloud/firestore');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview A "meta-calculation" that analyzes the results of
|
|
5
|
+
* 'crowd-cash-flow-proxy' and 'asset-crowd-flow' to correlate
|
|
6
|
+
* deposit events with subsequent asset purchases.
|
|
7
|
+
*/
|
|
8
|
+
class CashFlowDeployment {
|
|
9
|
+
constructor() {
|
|
10
|
+
// This calculation is stateless for `process()`, but `getResult()` is not used.
|
|
11
|
+
// All logic happens in `process()` which returns the result directly.
|
|
12
|
+
this.lookbackDays = 7; // How many days to look back for a signal
|
|
13
|
+
this.correlationWindow = 3; // How many days after the signal to track deployment
|
|
14
|
+
this.depositSignalThreshold = -1.0; // A -1.0% proxy value is a strong deposit signal
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Helper to get a YYYY-MM-DD string for N days ago.
|
|
19
|
+
*/
|
|
20
|
+
_getDateStr(baseDate, daysAgo) {
|
|
21
|
+
const date = new Date(baseDate + 'T00:00:00Z');
|
|
22
|
+
date.setUTCDate(date.getUTCDate() - daysAgo);
|
|
23
|
+
return date.toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fetches a single calculation result from Firestore.
|
|
28
|
+
*/
|
|
29
|
+
async _fetchCalc(db, collection, dateStr, category, computation) {
|
|
30
|
+
try {
|
|
31
|
+
const docRef = db.collection(collection).doc(dateStr)
|
|
32
|
+
.collection('results').doc(category)
|
|
33
|
+
.collection('computations').doc(computation);
|
|
34
|
+
const doc = await docRef.get();
|
|
35
|
+
if (!doc.exists) return null;
|
|
36
|
+
return doc.data();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* This special `process` method is called by the computation orchestrator *after*
|
|
44
|
+
* all user-data-based calculations are complete.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} dateStr - The "current" day being processed (e.g., "2025-10-30").
|
|
47
|
+
* @param {object} dependencies - The master dependencies object containing { db, logger }.
|
|
48
|
+
* @param {object} config - The computation system config.
|
|
49
|
+
* @returns {Promise<object|null>} The final result object for this day, or null.
|
|
50
|
+
*/
|
|
51
|
+
async process(dateStr, dependencies, config) {
|
|
52
|
+
const { db, logger } = dependencies;
|
|
53
|
+
const collection = config.resultsCollection;
|
|
54
|
+
|
|
55
|
+
let depositSignal = null;
|
|
56
|
+
let depositSignalDay = null;
|
|
57
|
+
|
|
58
|
+
// 1. Look back for a deposit signal
|
|
59
|
+
for (let i = 1; i <= this.lookbackDays; i++) {
|
|
60
|
+
const checkDate = this._getDateStr(dateStr, i);
|
|
61
|
+
const flowData = await this._fetchCalc(db, collection, checkDate, 'capital_flow', 'crowd-cash-flow-proxy');
|
|
62
|
+
|
|
63
|
+
if (flowData && flowData.cash_flow_effect_proxy < this.depositSignalThreshold) {
|
|
64
|
+
depositSignal = flowData;
|
|
65
|
+
depositSignalDay = checkDate;
|
|
66
|
+
break; // Found the most recent strong signal
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If no signal was found in the lookback window, stop.
|
|
71
|
+
if (!depositSignal) {
|
|
72
|
+
return {
|
|
73
|
+
status: 'no_signal_found',
|
|
74
|
+
lookback_days: this.lookbackDays,
|
|
75
|
+
signal_threshold: this.depositSignalThreshold
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. A signal was found. Now, track the "spend" in the following days.
|
|
80
|
+
const daysSinceSignal = (new Date(dateStr) - new Date(depositSignalDay)) / (1000 * 60 * 60 * 24);
|
|
81
|
+
|
|
82
|
+
// Only run this analysis for the N days *after* the signal
|
|
83
|
+
if (daysSinceSignal <= 0 || daysSinceSignal > this.correlationWindow) {
|
|
84
|
+
return {
|
|
85
|
+
status: 'outside_correlation_window',
|
|
86
|
+
signal_day: depositSignalDay,
|
|
87
|
+
days_since_signal: daysSinceSignal
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. We are INSIDE the correlation window. Let's analyze the deployment.
|
|
92
|
+
const [cashFlowData, assetFlowData] = await Promise.all([
|
|
93
|
+
this._fetchCalc(db, collection, dateStr, 'capital_flow', 'crowd-cash-flow-proxy'),
|
|
94
|
+
this._fetchCalc(db, collection, dateStr, 'behavioural', 'asset-crowd-flow')
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
if (!cashFlowData || !assetFlowData) {
|
|
98
|
+
logger.log('WARN', `[CashFlowDeployment] Missing dependency data for ${dateStr}. Skipping.`);
|
|
99
|
+
return null; // Missing data, can't run
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// This is the "spend" (net move from cash to assets) on this day
|
|
103
|
+
const netSpendPct = cashFlowData.components?.trading_effect || 0;
|
|
104
|
+
|
|
105
|
+
// This is the total deposit signal
|
|
106
|
+
const netDepositPct = Math.abs(depositSignal.cash_flow_effect_proxy);
|
|
107
|
+
|
|
108
|
+
// Filter asset flow to find the top 10 "buys"
|
|
109
|
+
const topBuys = Object.entries(assetFlowData)
|
|
110
|
+
.filter(([ticker, data]) => data.net_crowd_flow_pct > 0)
|
|
111
|
+
.sort(([, a], [, b]) => b.net_crowd_flow_pct - a.net_crowd_flow_pct)
|
|
112
|
+
.slice(0, 10)
|
|
113
|
+
.map(([ticker, data]) => ({
|
|
114
|
+
ticker: ticker,
|
|
115
|
+
net_flow_pct: data.net_crowd_flow_pct
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
// 4. Return the final result
|
|
119
|
+
return {
|
|
120
|
+
status: 'analysis_complete',
|
|
121
|
+
analysis_date: dateStr,
|
|
122
|
+
signal_date: depositSignalDay,
|
|
123
|
+
days_since_signal: daysSinceSignal,
|
|
124
|
+
signal_deposit_proxy_pct: netDepositPct,
|
|
125
|
+
day_net_spend_pct: netSpendPct,
|
|
126
|
+
// Calculate what percentage of the *total* deposit was spent *today*
|
|
127
|
+
pct_of_deposit_deployed_today: (netSpendPct / netDepositPct) * 100,
|
|
128
|
+
top_deployment_assets: topBuys
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* This calculation is stateless per-run, so getResult is not used.
|
|
134
|
+
* The orchestrator will call `process` and commit the result directly.
|
|
135
|
+
*/
|
|
136
|
+
async getResult() {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
reset() {
|
|
141
|
+
// Nothing to reset, as state is not carried
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = CashFlowDeployment;
|
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
|
@@ -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
|
+
};
|