aiden-shared-calculations-unified 1.0.28 → 1.0.30
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.
|
@@ -81,64 +81,85 @@ class AssetCrowdFlow {
|
|
|
81
81
|
|
|
82
82
|
async getResult() {
|
|
83
83
|
if (this.user_count === 0 || !this.dates.today) {
|
|
84
|
-
|
|
84
|
+
console.warn('[AssetCrowdFlow] No users processed or dates missing.');
|
|
85
|
+
return {};
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
// Load
|
|
88
|
+
// Load priceMap and mappings if not loaded
|
|
88
89
|
if (!this.priceMap || !this.mappings) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
try {
|
|
91
|
+
const [priceData, mappingData] = await Promise.all([
|
|
92
|
+
loadAllPriceData(),
|
|
93
|
+
loadInstrumentMappings()
|
|
94
|
+
]);
|
|
95
|
+
this.priceMap = priceData;
|
|
96
|
+
this.mappings = mappingData;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[AssetCrowdFlow] Failed to load dependencies:', err);
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
95
101
|
}
|
|
96
|
-
|
|
102
|
+
|
|
97
103
|
const finalResults = {};
|
|
98
104
|
const todayStr = this.dates.today;
|
|
99
105
|
const yesterdayStr = this.dates.yesterday;
|
|
100
106
|
|
|
101
|
-
for (const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const avg_day1_value = this.asset_values[
|
|
106
|
-
const avg_day2_value = this.asset_values[
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
for (const rawInstrumentId in this.asset_values) {
|
|
108
|
+
const instrumentId = String(rawInstrumentId); // normalize
|
|
109
|
+
const ticker = this.mappings.instrumentToTicker?.[instrumentId] || `id_${instrumentId}`;
|
|
110
|
+
|
|
111
|
+
const avg_day1_value = this.asset_values[rawInstrumentId].day1_value_sum / this.user_count;
|
|
112
|
+
const avg_day2_value = this.asset_values[rawInstrumentId].day2_value_sum / this.user_count;
|
|
113
|
+
|
|
114
|
+
let priceChangePct = null;
|
|
115
|
+
|
|
116
|
+
// Check priceMap presence
|
|
117
|
+
if (!this.priceMap || !this.priceMap[instrumentId]) {
|
|
118
|
+
console.debug(`[AssetCrowdFlow] Missing priceMap entry for instrumentId ${instrumentId} (${ticker})`);
|
|
119
|
+
} else {
|
|
120
|
+
const priceDay1 = this.priceMap[instrumentId][yesterdayStr];
|
|
121
|
+
const priceDay2 = this.priceMap[instrumentId][todayStr];
|
|
122
|
+
|
|
123
|
+
if (priceDay1 == null) console.debug(`[AssetCrowdFlow] Missing price for ${instrumentId} (${ticker}) on ${yesterdayStr}`);
|
|
124
|
+
if (priceDay2 == null) console.debug(`[AssetCrowdFlow] Missing price for ${instrumentId} (${ticker}) on ${todayStr}`);
|
|
125
|
+
|
|
126
|
+
if (priceDay1 != null && priceDay2 != null && priceDay1 > 0) {
|
|
127
|
+
priceChangePct = (priceDay2 - priceDay1) / priceDay1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
110
130
|
|
|
111
131
|
if (priceChangePct === null) {
|
|
112
|
-
// Cannot calculate if price data is missing for either day
|
|
113
132
|
finalResults[ticker] = {
|
|
114
133
|
net_crowd_flow_pct: 0,
|
|
115
|
-
|
|
134
|
+
avg_value_day1: avg_day1_value,
|
|
135
|
+
avg_value_day2: avg_day2_value,
|
|
136
|
+
price_change_pct: null,
|
|
137
|
+
user_sample_size: this.user_count,
|
|
138
|
+
warning: 'Missing price data for calculation'
|
|
116
139
|
};
|
|
117
140
|
continue;
|
|
118
141
|
}
|
|
119
142
|
|
|
120
|
-
//
|
|
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*.
|
|
143
|
+
// Calculate expected day2 value from price movement
|
|
124
144
|
const expected_day2_value = avg_day1_value * (1 + priceChangePct);
|
|
125
145
|
|
|
126
|
-
//
|
|
146
|
+
// Net crowd flow = actual minus expected
|
|
127
147
|
const net_crowd_flow_pct = avg_day2_value - expected_day2_value;
|
|
128
148
|
|
|
129
149
|
finalResults[ticker] = {
|
|
130
|
-
net_crowd_flow_pct
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
net_crowd_flow_pct,
|
|
151
|
+
avg_value_day1,
|
|
152
|
+
avg_value_day2,
|
|
153
|
+
expected_day2_value,
|
|
134
154
|
price_change_pct: priceChangePct,
|
|
135
155
|
user_sample_size: this.user_count
|
|
136
156
|
};
|
|
137
157
|
}
|
|
138
|
-
|
|
158
|
+
|
|
139
159
|
return finalResults;
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
|
|
142
163
|
reset() {
|
|
143
164
|
this.asset_values = {};
|
|
144
165
|
this.user_count = 0;
|
package/package.json
CHANGED
|
@@ -1,71 +1,151 @@
|
|
|
1
|
+
// utils/price_data_provider.js
|
|
1
2
|
const { Firestore } = require('@google-cloud/firestore');
|
|
2
3
|
const firestore = new Firestore();
|
|
3
4
|
|
|
4
|
-
// Config
|
|
5
5
|
const PRICE_COLLECTION = 'asset_prices';
|
|
6
6
|
const CACHE_DURATION_MS = 3600000; // 1 hour
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
let cache = {
|
|
10
|
-
timestamp: null,
|
|
11
|
-
priceMap: null, // Will be { instrumentId: { "YYYY-MM-DD": price, ... } }
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
// In-progress fetch promise
|
|
8
|
+
let cache = { timestamp: null, priceMap: null, meta: null };
|
|
15
9
|
let fetchPromise = null;
|
|
16
10
|
|
|
11
|
+
function isPlainObject(v) {
|
|
12
|
+
return v && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
}
|
|
14
|
+
|
|
17
15
|
/**
|
|
18
|
-
*
|
|
19
|
-
* This is
|
|
16
|
+
* Load all price data from 'asset_prices' collection.
|
|
17
|
+
* This loader is resilient to two common shapes:
|
|
18
|
+
* 1) Sharded docs: each doc contains many instrumentId keys:
|
|
19
|
+
* { "360": { prices: {...}, ticker: "NG.JUL23" }, "1028": { ... }, ... }
|
|
20
|
+
* 2) Per-instrument docs: each doc id is an instrumentId and doc contains prices:
|
|
21
|
+
* doc.id = "360", doc.data() = { prices: {...}, ticker: "NG.JUL23", lastUpdated: ... }
|
|
22
|
+
*
|
|
23
|
+
* The returned priceMap will always be:
|
|
24
|
+
* { "<instrumentId>": { prices: { "YYYY-MM-DD": number, ... }, ticker: string|null, lastUpdated: any|null } }
|
|
20
25
|
*/
|
|
21
26
|
async function loadAllPriceData() {
|
|
22
27
|
const now = Date.now();
|
|
23
28
|
if (cache.timestamp && (now - cache.timestamp < CACHE_DURATION_MS)) {
|
|
29
|
+
console.debug('[price_data_provider] returning cached priceMap', {
|
|
30
|
+
cachedAt: new Date(cache.timestamp).toISOString(),
|
|
31
|
+
instrumentCount: Object.keys(cache.priceMap || {}).length
|
|
32
|
+
});
|
|
24
33
|
return cache.priceMap;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
if (fetchPromise)
|
|
28
|
-
return fetchPromise;
|
|
29
|
-
}
|
|
36
|
+
if (fetchPromise) return fetchPromise;
|
|
30
37
|
|
|
31
38
|
fetchPromise = (async () => {
|
|
32
|
-
console.log('
|
|
39
|
+
console.log('[price_data_provider] fetching asset_prices collection from Firestore...');
|
|
33
40
|
const masterPriceMap = {};
|
|
34
|
-
|
|
41
|
+
let docCount = 0;
|
|
42
|
+
let shardDocs = 0;
|
|
43
|
+
let singleInstrumentDocs = 0;
|
|
35
44
|
try {
|
|
36
45
|
const snapshot = await firestore.collection(PRICE_COLLECTION).get();
|
|
37
|
-
|
|
38
46
|
if (snapshot.empty) {
|
|
39
|
-
|
|
47
|
+
console.warn(`[price_data_provider] collection "${PRICE_COLLECTION}" is empty`);
|
|
48
|
+
return {};
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
// Loop through each shard document (e.g., "shard_0", "shard_1")
|
|
43
51
|
snapshot.forEach(doc => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
docCount++;
|
|
53
|
+
const data = doc.data();
|
|
54
|
+
|
|
55
|
+
// Heuristic 1: if doc.data() has a 'prices' key and it's an object of date->number,
|
|
56
|
+
// treat this doc as a per-instrument document (doc.id = instrumentId).
|
|
57
|
+
if (isPlainObject(data) && 'prices' in data && isPlainObject(data.prices)) {
|
|
58
|
+
singleInstrumentDocs++;
|
|
59
|
+
const instrumentId = String(doc.id);
|
|
60
|
+
masterPriceMap[instrumentId] = {
|
|
61
|
+
prices: data.prices || {},
|
|
62
|
+
ticker: data.ticker || null,
|
|
63
|
+
lastUpdated: data.lastUpdated || null
|
|
64
|
+
};
|
|
65
|
+
return; // continue to next doc
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Heuristic 2: otherwise, check whether doc contains multiple instrumentId keys
|
|
69
|
+
// e.g., doc.data() looks like { "360": { prices: {...}, ticker: "NG.JUL23" }, "1028": { ... } }
|
|
70
|
+
const keys = Object.keys(data || {});
|
|
71
|
+
// detect shard-style by checking if at least one value is an object with .prices
|
|
72
|
+
const looksLikeShard = keys.length > 0 && keys.some(k => {
|
|
73
|
+
const v = data[k];
|
|
74
|
+
return isPlainObject(v) && ('prices' in v) && isPlainObject(v.prices);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (looksLikeShard) {
|
|
78
|
+
shardDocs++;
|
|
79
|
+
for (const instrumentIdRaw of keys) {
|
|
80
|
+
try {
|
|
81
|
+
const entry = data[instrumentIdRaw];
|
|
82
|
+
if (!entry || !isPlainObject(entry)) continue;
|
|
83
|
+
if (!('prices' in entry) || !isPlainObject(entry.prices)) continue;
|
|
84
|
+
|
|
85
|
+
const instrumentId = String(instrumentIdRaw);
|
|
86
|
+
masterPriceMap[instrumentId] = {
|
|
87
|
+
prices: entry.prices || {},
|
|
88
|
+
ticker: entry.ticker || null,
|
|
89
|
+
lastUpdated: entry.lastUpdated || null
|
|
90
|
+
};
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// resilience: skip malformed instrument entry
|
|
93
|
+
console.debug('[price_data_provider] skipping malformed entry in shard', {
|
|
94
|
+
docId: doc.id,
|
|
95
|
+
instrumentIdRaw,
|
|
96
|
+
err: e && e.message
|
|
97
|
+
});
|
|
98
|
+
}
|
|
51
99
|
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If we get here, doc shape is unexpected. Log it for debugging and attempt
|
|
104
|
+
// a best-effort extraction: if doc keys look numeric and values are numbers
|
|
105
|
+
// or nested simple maps, we may try to treat doc.id as instrumentId fallback.
|
|
106
|
+
console.debug('[price_data_provider] unexpected doc shape in asset_prices', {
|
|
107
|
+
docId: doc.id,
|
|
108
|
+
sampleKeys: Object.keys(data || {}).slice(0, 5)
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Fallback: if doc has top-level numeric-like id and a single 'prices' like nested object, try doc.id
|
|
112
|
+
if (isPlainObject(data) && Object.keys(data).length > 0 && 'prices' in data) {
|
|
113
|
+
const instrumentId = String(doc.id);
|
|
114
|
+
masterPriceMap[instrumentId] = {
|
|
115
|
+
prices: data.prices || {},
|
|
116
|
+
ticker: data.ticker || null,
|
|
117
|
+
lastUpdated: data.lastUpdated || null
|
|
118
|
+
};
|
|
52
119
|
}
|
|
53
120
|
});
|
|
54
121
|
|
|
55
122
|
cache = {
|
|
56
123
|
timestamp: now,
|
|
57
124
|
priceMap: masterPriceMap,
|
|
125
|
+
meta: {
|
|
126
|
+
instrumentCount: Object.keys(masterPriceMap).length,
|
|
127
|
+
docCount,
|
|
128
|
+
shardDocs,
|
|
129
|
+
singleInstrumentDocs
|
|
130
|
+
}
|
|
58
131
|
};
|
|
59
|
-
|
|
60
|
-
console.log(`Successfully cached prices for ${Object.keys(masterPriceMap).length} instruments.`);
|
|
61
|
-
return masterPriceMap;
|
|
62
132
|
|
|
133
|
+
console.log('[price_data_provider] load complete', cache.meta);
|
|
134
|
+
|
|
135
|
+
// also print a small sample to help debugging quickly
|
|
136
|
+
const sampleIds = Object.keys(masterPriceMap).slice(0, 10);
|
|
137
|
+
const samplePreview = sampleIds.map(id => ({
|
|
138
|
+
id,
|
|
139
|
+
ticker: masterPriceMap[id].ticker || null,
|
|
140
|
+
sampleDates: Object.keys(masterPriceMap[id].prices || {}).slice(0, 3)
|
|
141
|
+
}));
|
|
142
|
+
console.debug('[price_data_provider] sample preview:', samplePreview);
|
|
143
|
+
|
|
144
|
+
return masterPriceMap;
|
|
63
145
|
} catch (err) {
|
|
64
|
-
console.error('
|
|
65
|
-
// On error, return an empty map but don't cache, so a future call can retry.
|
|
146
|
+
console.error('[price_data_provider] error loading price data:', err);
|
|
66
147
|
return {};
|
|
67
148
|
} finally {
|
|
68
|
-
// Clear the promise so the next call (if cache is stale) triggers a new fetch
|
|
69
149
|
fetchPromise = null;
|
|
70
150
|
}
|
|
71
151
|
})();
|
|
@@ -74,30 +154,40 @@ async function loadAllPriceData() {
|
|
|
74
154
|
}
|
|
75
155
|
|
|
76
156
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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.
|
|
157
|
+
* Get daily price change percentage. Accepts priceMap with shape produced by loadAllPriceData().
|
|
158
|
+
* Returns number (e.g., 0.1 for +10%) or null if not available.
|
|
83
159
|
*/
|
|
84
160
|
function getDailyPriceChange(instrumentId, yesterdayStr, todayStr, priceMap) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const priceDay1 = priceMap[instrumentId][yesterdayStr];
|
|
90
|
-
const priceDay2 = priceMap[instrumentId][todayStr];
|
|
161
|
+
if (!priceMap) return null;
|
|
162
|
+
const id = String(instrumentId);
|
|
163
|
+
const entry = priceMap[id];
|
|
91
164
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return null; // Missing one or both dates, or division by zero
|
|
97
|
-
}
|
|
165
|
+
if (!entry) return null;
|
|
166
|
+
|
|
167
|
+
// entry may be { prices: {...}, ticker, lastUpdated } OR legacy style where entry is the raw prices map
|
|
168
|
+
const prices = (isPlainObject(entry) && 'prices' in entry) ? entry.prices : entry;
|
|
98
169
|
|
|
170
|
+
if (!isPlainObject(prices)) return null;
|
|
171
|
+
|
|
172
|
+
const p1 = prices[yesterdayStr];
|
|
173
|
+
const p2 = prices[todayStr];
|
|
174
|
+
|
|
175
|
+
// log missing specifics for traceability (not too spammy)
|
|
176
|
+
if (p1 == null || p2 == null) {
|
|
177
|
+
// caller should already log, but this helps track date mismatches
|
|
178
|
+
console.debug('[price_data_provider] missing date price', { instrumentId: id, yesterdayStr, todayStr, hasP1: p1 != null, hasP2: p2 != null });
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (typeof p1 !== 'number' || typeof p2 !== 'number' || p1 <= 0) {
|
|
183
|
+
console.debug('[price_data_provider] invalid price values', { instrumentId: id, p1, p2 });
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (p2 - p1) / p1;
|
|
188
|
+
}
|
|
99
189
|
|
|
100
190
|
module.exports = {
|
|
101
|
-
|
|
102
|
-
|
|
191
|
+
loadAllPriceData,
|
|
192
|
+
getDailyPriceChange
|
|
103
193
|
};
|