aiden-shared-calculations-unified 1.0.30 → 1.0.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiden-shared-calculations-unified",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Shared calculation modules for the BullTrackers Computation System.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -1,151 +1,99 @@
1
- // utils/price_data_provider.js
2
1
  const { Firestore } = require('@google-cloud/firestore');
3
2
  const firestore = new Firestore();
4
3
 
4
+ // Config
5
5
  const PRICE_COLLECTION = 'asset_prices';
6
6
  const CACHE_DURATION_MS = 3600000; // 1 hour
7
7
 
8
- let cache = { timestamp: null, priceMap: null, meta: null };
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
9
15
  let fetchPromise = null;
10
16
 
11
- function isPlainObject(v) {
12
- return v && typeof v === 'object' && !Array.isArray(v);
17
+ /**
18
+ * Finds the most recent available price on or before a given date.
19
+ * @param {object} priceHistory - The map of { "YYYY-MM-DD": price }
20
+ * @param {string} dateStr - The target date string to start searching from.
21
+ * @param {number} [maxLookback=5] - Max days to look back (to skip weekends/holidays).
22
+ * @returns {number|null} The price, or null if not found.
23
+ */
24
+ function _findPreviousAvailablePrice(priceHistory, dateStr, maxLookback = 5) {
25
+ if (!priceHistory) return null;
26
+
27
+ let checkDate = new Date(dateStr + 'T00:00:00Z');
28
+
29
+ for (let i = 0; i < maxLookback; i++) {
30
+ const checkDateStr = checkDate.toISOString().slice(0, 10);
31
+ const price = priceHistory[checkDateStr];
32
+
33
+ if (price !== undefined && price !== null && price > 0) {
34
+ return price; // Found it
35
+ }
36
+
37
+ // If not found, look back one more day
38
+ checkDate.setUTCDate(checkDate.getUTCDate() - 1);
39
+ }
40
+
41
+ // console.warn(`No price found for instrument within ${maxLookback} days of ${dateStr}`);
42
+ return null; // No price found within lookback
13
43
  }
14
44
 
15
45
  /**
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 } }
46
+ * Loads all sharded price data from the `asset_prices` collection.
47
+ * This is a heavy operation and should be cached.
25
48
  */
26
49
  async function loadAllPriceData() {
27
50
  const now = Date.now();
28
51
  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
- });
33
52
  return cache.priceMap;
34
53
  }
35
54
 
36
- if (fetchPromise) return fetchPromise;
55
+ if (fetchPromise) {
56
+ return fetchPromise;
57
+ }
37
58
 
38
59
  fetchPromise = (async () => {
39
- console.log('[price_data_provider] fetching asset_prices collection from Firestore...');
60
+ console.log('Fetching and caching all asset price data...');
40
61
  const masterPriceMap = {};
41
- let docCount = 0;
42
- let shardDocs = 0;
43
- let singleInstrumentDocs = 0;
62
+
44
63
  try {
45
64
  const snapshot = await firestore.collection(PRICE_COLLECTION).get();
65
+
46
66
  if (snapshot.empty) {
47
- console.warn(`[price_data_provider] collection "${PRICE_COLLECTION}" is empty`);
48
- return {};
67
+ throw new Error(`Price collection '${PRICE_COLLECTION}' is empty.`);
49
68
  }
50
69
 
70
+ // Loop through each shard document (e.g., "shard_0", "shard_1")
51
71
  snapshot.forEach(doc => {
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
- }
72
+ const shardData = doc.data();
73
+
74
+ // Loop through each instrumentId in the shard
75
+ for (const instrumentId in shardData) {
76
+ // Check if it's a valid instrument entry
77
+ if (shardData[instrumentId] && shardData[instrumentId].prices) {
78
+ masterPriceMap[instrumentId] = shardData[instrumentId].prices;
99
79
  }
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
- };
119
80
  }
120
81
  });
121
82
 
122
83
  cache = {
123
84
  timestamp: now,
124
85
  priceMap: masterPriceMap,
125
- meta: {
126
- instrumentCount: Object.keys(masterPriceMap).length,
127
- docCount,
128
- shardDocs,
129
- singleInstrumentDocs
130
- }
131
86
  };
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
-
87
+
88
+ console.log(`Successfully cached prices for ${Object.keys(masterPriceMap).length} instruments.`);
144
89
  return masterPriceMap;
90
+
145
91
  } catch (err) {
146
- console.error('[price_data_provider] error loading price data:', err);
92
+ console.error('CRITICAL: Error loading price data:', err);
93
+ // On error, return an empty map but don't cache, so a future call can retry.
147
94
  return {};
148
95
  } finally {
96
+ // Clear the promise so the next call (if cache is stale) triggers a new fetch
149
97
  fetchPromise = null;
150
98
  }
151
99
  })();
@@ -154,40 +102,41 @@ async function loadAllPriceData() {
154
102
  }
155
103
 
156
104
  /**
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.
105
+ * A helper to safely get the price change percentage between two dates.
106
+ * --- THIS IS THE MODIFIED FUNCTION ---
107
+ * @param {string} instrumentId - The instrument ID.
108
+ * @param {string} yesterdayStr - YYYY-MM-DD date string for yesterday.
109
+ * @param {string} todayStr - YYYY-MM-DD date string for today.
110
+ * @param {object} priceMap - The master price map from loadAllPriceData().
111
+ * @returns {number|null} The percentage change (e.g., 0.10 for +10%), or null if data is missing.
159
112
  */
160
113
  function getDailyPriceChange(instrumentId, yesterdayStr, todayStr, priceMap) {
161
- if (!priceMap) return null;
162
- const id = String(instrumentId);
163
- const entry = priceMap[id];
164
-
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;
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;
114
+ if (!priceMap || !priceMap[instrumentId]) {
115
+ return null; // No price data for this instrument
116
+ }
117
+
118
+ const instrumentPrices = priceMap[instrumentId];
119
+
120
+ // Find the most recent available price *on or before* the target dates
121
+ const priceDay1 = _findPreviousAvailablePrice(instrumentPrices, yesterdayStr, 5);
122
+ const priceDay2 = _findPreviousAvailablePrice(instrumentPrices, todayStr, 5);
123
+
124
+ if (priceDay1 && priceDay2) {
125
+ // We found prices, now check if they are the *same* price
126
+ // (e.g., if today is Sunday, priceDay2 would be Friday's price.
127
+ // If yesterday was Saturday, priceDay1 would *also* be Friday's price).
128
+ if (priceDay1 === priceDay2) {
129
+ return 0.0; // No change between the two most recent *available* dates
130
+ }
131
+ return (priceDay2 - priceDay1) / priceDay1;
132
+ }
133
+
134
+ return null; // Missing one or both dates, even after lookback
188
135
  }
189
136
 
137
+
190
138
  module.exports = {
191
- loadAllPriceData,
192
- getDailyPriceChange
139
+ loadAllPriceData,
140
+ getDailyPriceChange
141
+ // Add the helper to exports if you want, but it's not required
193
142
  };