bulltrackers-module 1.0.170 → 1.0.172

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,346 @@
1
+ /**
2
+ * @fileoverview Math Layer - Single Source of Truth (V3 Final)
3
+ * Encapsulates Schema Knowledge, Mathematical Primitives, and Signal Extractors.
4
+ * * STRICT COMPLIANCE:
5
+ * - Adheres to 'schema.md' definitions for Normal vs Speculator portfolios.
6
+ * - standardizes access to P&L, Weights, and Rates.
7
+ * - Provides safe fallbacks for all fields.
8
+ */
9
+
10
+ const SCHEMAS = {
11
+ USER_TYPES: { NORMAL: 'normal', SPECULATOR: 'speculator' }
12
+ };
13
+
14
+ class DataExtractor {
15
+ // ========================================================================
16
+ // 1. COLLECTION ACCESSORS
17
+ // ========================================================================
18
+
19
+ /**
20
+ * Extract positions array based on User Type.
21
+ * - Normal: Uses 'AggregatedPositions' (Grouped by Asset + Direction)
22
+ * - Speculator: Uses 'PublicPositions' (Individual Trades)
23
+ */
24
+ static getPositions(portfolio, userType) {
25
+ if (!portfolio) return [];
26
+
27
+ if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
28
+ return portfolio.PublicPositions || [];
29
+ }
30
+
31
+ // Default to Normal User Schema
32
+ return portfolio.AggregatedPositions || [];
33
+ }
34
+
35
+ // ========================================================================
36
+ // 2. IDENTITY & KEYS
37
+ // ========================================================================
38
+
39
+ /**
40
+ * Extract standardized Instrument ID.
41
+ */
42
+ static getInstrumentId(position) {
43
+ if (!position) return null;
44
+ // Handle string or number variations safely
45
+ return position.InstrumentID || position.instrumentId || null;
46
+ }
47
+
48
+ /**
49
+ * Extract a unique Identifier for the position.
50
+ * - Speculator: Uses 'PositionID'.
51
+ * - Normal: Generates Composite Key (InstrumentID_Direction) since they lack unique Trade IDs.
52
+ */
53
+ static getPositionId(position) {
54
+ if (!position) return null;
55
+
56
+ // 1. Try Explicit ID (Speculators)
57
+ if (position.PositionID) return String(position.PositionID);
58
+ if (position.PositionId) return String(position.PositionId);
59
+
60
+ // 2. Fallback to Composite Key (Normal Users)
61
+ const instId = this.getInstrumentId(position);
62
+ const dir = this.getDirection(position);
63
+ if (instId) return `${instId}_${dir}`;
64
+
65
+ return null;
66
+ }
67
+
68
+ // ========================================================================
69
+ // 3. FINANCIAL METRICS (WEIGHTS & P&L)
70
+ // ========================================================================
71
+
72
+ /**
73
+ * Extract Net Profit %.
74
+ * Schema: 'NetProfit' is the percentage profit relative to invested capital.
75
+ */
76
+ static getNetProfit(position) {
77
+ return position ? (position.NetProfit || 0) : 0;
78
+ }
79
+
80
+ /**
81
+ * Extract Position Weight (Allocation %).
82
+ * Schema:
83
+ * - Normal: 'Invested' is % of initial capital.
84
+ * - Speculator: 'Invested' (or 'Amount' in some contexts) is % of initial capital.
85
+ */
86
+ static getPositionWeight(position, userType) {
87
+ if (!position) return 0;
88
+
89
+ // Both schemas use 'Invested' to represent the allocation percentage.
90
+ // Speculators might optionally have 'Amount', we prioritize 'Invested' for consistency.
91
+ return position.Invested || position.Amount || 0;
92
+ }
93
+
94
+ /**
95
+ * Extract Current Equity Value %.
96
+ * Schema: 'Value' is the current value as a % of total portfolio equity.
97
+ */
98
+ static getPositionValuePct(position) {
99
+ return position ? (position.Value || 0) : 0;
100
+ }
101
+
102
+ // ========================================================================
103
+ // 4. PORTFOLIO LEVEL SUMMARY
104
+ // ========================================================================
105
+
106
+ /**
107
+ * Calculate/Extract Daily Portfolio P&L %.
108
+ */
109
+ static getPortfolioDailyPnl(portfolio, userType) {
110
+ if (!portfolio) return 0;
111
+
112
+ // 1. Speculator (Explicit 'NetProfit' field on root)
113
+ if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
114
+ return portfolio.NetProfit || 0;
115
+ }
116
+
117
+ // 2. Normal (Aggregated Calculation)
118
+ // Logic: Sum(Value - Invested) across the 'InstrumentType' breakdown
119
+ // This gives the net performance change for the day relative to the portfolio.
120
+ if (portfolio.AggregatedPositionsByInstrumentTypeID) {
121
+ return portfolio.AggregatedPositionsByInstrumentTypeID.reduce((sum, agg) => {
122
+ return sum + ((agg.Value || 0) - (agg.Invested || 0));
123
+ }, 0);
124
+ }
125
+
126
+ return 0;
127
+ }
128
+
129
+ // ========================================================================
130
+ // 5. TRADE DETAILS (SPECULATOR SPECIFIC)
131
+ // ========================================================================
132
+
133
+ static getDirection(position) {
134
+ if (!position) return "Buy";
135
+ if (position.Direction) return position.Direction;
136
+ if (typeof position.IsBuy === 'boolean') return position.IsBuy ? "Buy" : "Sell";
137
+ return "Buy"; // Default
138
+ }
139
+
140
+ static getLeverage(position) {
141
+ return position ? (position.Leverage || 1) : 1;
142
+ }
143
+
144
+ static getOpenRate(position) {
145
+ return position ? (position.OpenRate || 0) : 0;
146
+ }
147
+
148
+ static getCurrentRate(position) {
149
+ return position ? (position.CurrentRate || 0) : 0;
150
+ }
151
+
152
+ static getStopLossRate(position) {
153
+ return position ? (position.StopLossRate || 0) : 0;
154
+ }
155
+
156
+ static getTakeProfitRate(position) {
157
+ return position ? (position.TakeProfitRate || 0) : 0;
158
+ }
159
+
160
+ static getHasTSL(position) {
161
+ return position ? (position.HasTrailingStopLoss === true) : false;
162
+ }
163
+
164
+ /**
165
+ * Extract Open Date Time.
166
+ * Used for bagholder calculations.
167
+ */
168
+ static getOpenDateTime(position) {
169
+ if (!position || !position.OpenDateTime) return null;
170
+ return new Date(position.OpenDateTime);
171
+ }
172
+ }
173
+
174
+ class HistoryExtractor {
175
+ // --- Schema Accessor (NEW) ---
176
+ /**
177
+ * Extracts the daily history snapshot from the User object.
178
+ * This decouples the computation from knowing 'user.history.today'.
179
+ */
180
+ static getDailyHistory(user) {
181
+ return user?.history?.today || null;
182
+ }
183
+
184
+ // --- Data Extractors ---
185
+ static getTradedAssets(historyDoc) {
186
+ if (!historyDoc || !Array.isArray(historyDoc.assets)) return [];
187
+ return historyDoc.assets;
188
+ }
189
+
190
+ static getInstrumentId(asset) {
191
+ return asset ? asset.instrumentId : null;
192
+ }
193
+
194
+ static getAvgHoldingTimeMinutes(asset) {
195
+ return asset ? (asset.avgHoldingTimeInMinutes || 0) : 0;
196
+ }
197
+
198
+ static getSummary(historyDoc) {
199
+ const all = historyDoc?.all;
200
+ if (!all) return null;
201
+
202
+ return {
203
+ totalTrades: all.totalTrades || 0,
204
+ winRatio: all.winRatio || 0,
205
+ avgProfitPct: all.avgProfitPct || 0,
206
+ avgLossPct: all.avgLossPct || 0,
207
+ avgHoldingTimeInMinutes: all.avgHoldingTimeInMinutes || 0
208
+ };
209
+ }
210
+ }
211
+
212
+ class SignalPrimitives {
213
+ /**
214
+ * Safely extracts a specific numeric field for a specific ticker from a dependency.
215
+ */
216
+ static getMetric(dependencies, calcName, ticker, fieldName, fallback = 0) {
217
+ if (!dependencies || !dependencies[calcName]) return fallback;
218
+ const tickerData = dependencies[calcName][ticker];
219
+ if (!tickerData) return fallback;
220
+
221
+ const val = tickerData[fieldName];
222
+ return (typeof val === 'number') ? val : fallback;
223
+ }
224
+
225
+ /**
226
+ * Creates a unified Set of all keys (tickers) present across multiple dependency results.
227
+ */
228
+ static getUnionKeys(dependencies, calcNames) {
229
+ const keys = new Set();
230
+ if (!dependencies) return [];
231
+
232
+ for (const name of calcNames) {
233
+ const resultObj = dependencies[name];
234
+ if (resultObj && typeof resultObj === 'object') {
235
+ Object.keys(resultObj).forEach(k => keys.add(k));
236
+ }
237
+ }
238
+ return Array.from(keys);
239
+ }
240
+
241
+ /**
242
+ * Hyperbolic Tangent Normalization.
243
+ * Maps inputs to a strict -Scale to +Scale range.
244
+ */
245
+ static normalizeTanh(value, scale = 10, sensitivity = 10.0) {
246
+ if (value === 0) return 0;
247
+ return Math.tanh(value / sensitivity) * scale;
248
+ }
249
+
250
+ /**
251
+ * Standard Z-Score normalization.
252
+ */
253
+ static normalizeZScore(value, mean, stdDev) {
254
+ if (!stdDev || stdDev === 0) return 0;
255
+ return (value - mean) / stdDev;
256
+ }
257
+
258
+ /**
259
+ * Simple Divergence (A - B).
260
+ */
261
+ static divergence(valueA, valueB) {
262
+ return (valueA || 0) - (valueB || 0);
263
+ }
264
+ }
265
+
266
+ class MathPrimitives {
267
+ static average(values) {
268
+ if (!values || !values.length) return 0;
269
+ return values.reduce((a, b) => a + b, 0) / values.length;
270
+ }
271
+
272
+ static median(values) {
273
+ if (!values || !values.length) return 0;
274
+ const sorted = [...values].sort((a, b) => a - b);
275
+ const mid = Math.floor(sorted.length / 2);
276
+ return sorted.length % 2 === 0
277
+ ? (sorted[mid - 1] + sorted[mid]) / 2
278
+ : sorted[mid];
279
+ }
280
+
281
+ static standardDeviation(values) {
282
+ if (!values || !values.length) return 0;
283
+ const avg = this.average(values);
284
+ const squareDiffs = values.map(val => Math.pow((val || 0) - avg, 2));
285
+ return Math.sqrt(this.average(squareDiffs));
286
+ }
287
+
288
+ static bucketBinary(value, threshold = 0) {
289
+ return value > threshold ? 'winner' : 'loser';
290
+ }
291
+ }
292
+
293
+ class Aggregators {
294
+ /**
295
+ * Helper to bucket users by P&L Status.
296
+ * Used by legacy systems or specific aggregators.
297
+ */
298
+ static bucketUsersByPnlPerAsset(usersData, tickerMap) {
299
+ const buckets = new Map();
300
+ for (const [userId, portfolio] of Object.entries(usersData)) {
301
+ // Auto-detect type if not provided (Legacy compatibility)
302
+ const userType = portfolio.PublicPositions ? SCHEMAS.USER_TYPES.SPECULATOR : SCHEMAS.USER_TYPES.NORMAL;
303
+ const positions = DataExtractor.getPositions(portfolio, userType);
304
+
305
+ for (const pos of positions) {
306
+ const id = DataExtractor.getInstrumentId(pos);
307
+ const pnl = DataExtractor.getNetProfit(pos);
308
+ if (!id || pnl === 0) continue;
309
+
310
+ const ticker = tickerMap[id];
311
+ if (!ticker) continue;
312
+
313
+ if (!buckets.has(ticker)) buckets.set(ticker, { winners: [], losers: [] });
314
+ const b = buckets.get(ticker);
315
+
316
+ if (pnl > 0) b.winners.push(userId);
317
+ else b.losers.push(userId);
318
+ }
319
+ }
320
+ return Object.fromEntries(buckets);
321
+ }
322
+ }
323
+
324
+ class Validators {
325
+ static validatePortfolio(portfolio, userType) {
326
+ if (!portfolio) return { valid: false, errors: ['Portfolio is null'] };
327
+
328
+ if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
329
+ if (!portfolio.PublicPositions) return { valid: false, errors: ['Missing PublicPositions'] };
330
+ } else {
331
+ if (!portfolio.AggregatedPositions) return { valid: false, errors: ['Missing AggregatedPositions'] };
332
+ }
333
+
334
+ return { valid: true, errors: [] };
335
+ }
336
+ }
337
+
338
+ module.exports = {
339
+ SCHEMAS,
340
+ DataExtractor,
341
+ HistoryExtractor,
342
+ MathPrimitives,
343
+ Aggregators,
344
+ Validators,
345
+ SignalPrimitives
346
+ };
@@ -24,8 +24,7 @@ async function handleDiscover(task, taskId, dependencies, config) {
24
24
  const url = `${config.ETORO_API_RANKINGS_URL}?Period=LastTwoYears`;
25
25
 
26
26
  const selectedHeader = await headerManager.selectHeader();
27
- if (!selectedHeader) {
28
- logger.log('ERROR', `[DISCOVER/${taskId}] Could not select a header. Aborting task.`);
27
+ if (!selectedHeader) { logger.log('ERROR', `[DISCOVER/${taskId}] Could not select a header. Aborting task.`);
29
28
  throw new Error("Could not select a header.");
30
29
  }
31
30
 
@@ -35,45 +34,34 @@ async function handleDiscover(task, taskId, dependencies, config) {
35
34
 
36
35
  try { // Outer try for the whole operation
37
36
  let response;
38
- const options = {
39
- method: 'POST',
40
- headers: { ...selectedHeader.header, 'Content-Type': 'application/json' },
41
- body: JSON.stringify(cids),
42
- };
37
+ const options = { method: 'POST', headers: { ...selectedHeader.header, 'Content-Type': 'application/json' }, body: JSON.stringify(cids), };
43
38
 
44
39
  logger.log('INFO', `${logPrefix} Starting discovery for ${cids.length} CIDs. Block: ${blockId}, Type: ${userType}`);
45
40
 
46
41
  try {
47
42
  // --- REFACTOR 3: ADD FALLBACK ---
48
- logger.log('TRACE', `${logPrefix} Attempting discovery fetch via AppScript proxy...`);
43
+ logger.log('TRACE', `${logPrefix} Attempting discovery fetch via AppScript proxy...`);
49
44
  response = await proxyManager.fetch(url, options);
50
- if (!response.ok) throw new Error(`AppScript proxy failed with status ${response.status}`);
45
+ if (!response.ok) throw new Error(`AppScript proxy failed with status ${response.status}`); // Failure
51
46
 
52
- } catch (proxyError) {
53
- logger.log('WARN', `${logPrefix} AppScript proxy fetch FAILED. Error: ${proxyError.message}. Attempting direct node-fetch fallback.`, {
54
- error: proxyError.message,
55
- source: 'AppScript'
56
- });
47
+ } catch (proxyError) { // Fuck we failed with appscript IP Pools, log error type
48
+ logger.log('WARN', `${logPrefix} AppScript proxy fetch FAILED. Error: ${proxyError.message}. Attempting direct node-fetch fallback.`, { error: proxyError.message,source: 'AppScript' });
57
49
  proxyUsed = false;
58
50
 
59
51
  try {
60
- response = await fetch(url, options); // Direct node-fetch
61
- if (!response.ok) {
62
- const errorText = await response.text();
63
- throw new Error(`Direct fetch failed with status ${response.status}. Response: ${errorText.substring(0, 200)}`);
52
+ response = await fetch(url, options); // Direct node-fetch fallback, use GCP IP Pools
53
+ if (!response.ok) { const errorText = await response.text();
54
+ throw new Error(`Direct fetch failed with status ${response.status}. Response: ${errorText.substring(0, 200)}`); // Fuck we failed here too.
64
55
  }
65
56
 
66
- } catch (fallbackError) {
67
- logger.log('ERROR', `${logPrefix} Direct node-fetch fallback FAILED.`, {
68
- error: fallbackError.message,
69
- source: 'eToro/Network'
70
- });
57
+ } catch (fallbackError) { // Figure out the error type
58
+ logger.log('ERROR', `${logPrefix} Direct node-fetch fallback FAILED.`, { error: fallbackError.message, source: 'eToro/Network' });
71
59
  throw fallbackError; // Throw to be caught by outer try
72
60
  }
73
61
  // --- END REFACTOR 3 ---
74
62
  }
75
63
 
76
- // --- If we are here, `response` is valid ---
64
+ // --- If we are here, `response` is valid and we are very smart ---
77
65
 
78
66
  // Step 1. Discover Speculators
79
67
  if (userType === 'speculator') {
@@ -87,17 +75,12 @@ async function handleDiscover(task, taskId, dependencies, config) {
87
75
  // --- REFACTOR 4: LOG RAW RESPONSE ON PARSE FAILURE ---
88
76
  publicUsers = JSON.parse(body);
89
77
  } catch (parseError) {
90
- logger.log('ERROR', `${logPrefix} FAILED TO PARSE JSON RESPONSE. RAW BODY:`, {
91
- parseErrorMessage: parseError.message,
92
- rawResponseText: body
93
- });
78
+ logger.log('ERROR', `${logPrefix} FAILED TO PARSE JSON RESPONSE. RAW BODY:`, { parseErrorMessage: parseError.message, rawResponseText: body });
94
79
  throw new Error(`Failed to parse JSON response from discovery API. Body: ${body.substring(0, 200)}`);
95
80
  }
96
81
  // --- END REFACTOR 4 ---
97
82
 
98
- if (!Array.isArray(publicUsers)) {
99
- logger.log('WARN', `${logPrefix} API returned non-array response. Type: ${typeof publicUsers}`);
100
- wasSuccess = true; // API call worked, data was just empty/weird
83
+ if (!Array.isArray(publicUsers)) { logger.log('WARN', `${logPrefix} API returned non-array response. Type: ${typeof publicUsers}`); wasSuccess = true; // API call worked, data was just empty/weird
101
84
  return;
102
85
  }
103
86
 
@@ -110,9 +93,9 @@ async function handleDiscover(task, taskId, dependencies, config) {
110
93
  // Step 2. Filter active users
111
94
  const preliminaryActiveUsers = publicUsers.filter(user =>
112
95
  new Date(user.Value.LastActivity) > oneMonthAgo &&
113
- user.Value.DailyGain !== 0 &&
114
- user.Value.Exposure !== 0 &&
115
- user.Value.RiskScore !== 0
96
+ user.Value.DailyGain !== 0 && // Daily % gain, if 0 then they can't hold positions --- or its a weekend and portfolio holds solely stocks
97
+ user.Value.Exposure !== 0 && // Not sure what this means exactly but exposure should refer to position % held/invested in
98
+ user.Value.RiskScore !== 0 // If portfolio has risk score 0, they cannot have positions
116
99
  );
117
100
  logger.log('INFO', `${logPrefix} Found ${preliminaryActiveUsers.length} preliminary active users.`);
118
101
 
@@ -136,26 +119,27 @@ async function handleDiscover(task, taskId, dependencies, config) {
136
119
  const nonSpeculatorCids = [];
137
120
  for (const user of preliminaryActiveUsers) {
138
121
  const v = user.Value;
139
- const totalLeverage = (v.MediumLeveragePct || 0) + (v.HighLeveragePct || 0);
140
- const isLikelySpeculator = ((v.Trades || 0) > 500 || (v.TotalTradedInstruments || 0) > 50 || totalLeverage > 50 || (v.WeeklyDD || 0) < -25);
122
+ const totalLeverage = (v.MediumLeveragePct || 0) + (v.HighLeveragePct || 0); // Do they own at least some leveraged positions?
123
+ // We don't know what they are, but we can see their total average leverage, if not 0 on medoum/high leverage, then they MUST have at least 1 leveraged position
124
+ // Note : Medium leverage and high leverage are somewhat arbitrary. Low leverage is presumed to be mean "1" and thus medium leverage is the middle of the available leverage ranges, for stocks this is usually 2, for other assets this can be 5/10 or 20, it varies. High leverage is presumed to be the max leverage for that asset
125
+ const isLikelySpeculator = ((v.Trades || 0) > 500 || (v.TotalTradedInstruments || 0) > 50 || totalLeverage > 50 || (v.WeeklyDD || 0) < -25); // Apply filtering on their total leverage, drawdown and trade numbers to figure out if they are likely a speculative portfolio.
141
126
 
142
- if (isLikelySpeculator) {
143
- finalActiveUsers.push(user);
144
- } else {
145
- nonSpeculatorCids.push(user.CID);
146
- }
127
+ if (isLikelySpeculator) { finalActiveUsers.push(user); } else { nonSpeculatorCids.push(user.CID); }
147
128
  }
148
- invalidCidsToLog.push(...nonSpeculatorCids);
129
+ invalidCidsToLog.push(...nonSpeculatorCids); //
130
+ // This logs how many users did NOT pass SPECIFICALLY the islikelyspeculator
149
131
  logger.log('INFO', `${logPrefix} Speculator pre-filter complete. ${finalActiveUsers.length} users passed. ${nonSpeculatorCids.length} users failed heuristic.`);
150
132
 
151
- if (invalidCidsToLog.length > 0) {
133
+ if (invalidCidsToLog.length > 0) { // Log the number of users we tried for speculators that failed the filtering checks.
134
+ // NOTE : This logs the users who failed ANY of the 3 filters (private users, inactive public users, or users who didn't meet the specific conditions for islikelyspeculator)
152
135
  await pubsub.topic(config.PUBSUB_TOPIC_INVALID_SPECULATOR_LOG).publishMessage({ json: { invalidCids: invalidCidsToLog } });
153
136
  logger.log('INFO', `${logPrefix} Reported ${invalidCidsToLog.length} invalid (private, inactive, or failed heuristic) speculator IDs.`);
154
137
  }
155
- // Step 5. Non-speculators are just active users
156
- } else {
157
- finalActiveUsers = preliminaryActiveUsers;
158
- }
138
+ // Step 5. For non-speculator tasks, we keep all users who passed the
139
+ // “active user” filters. Unlike speculators, normal users do NOT require
140
+ // the speculative-heuristic (leverage / trades / drawdown) filter —
141
+ // any public & active user is valid.
142
+ } else { finalActiveUsers = preliminaryActiveUsers; }
159
143
 
160
144
  // Step 6. Publish 'verify' task
161
145
  if (finalActiveUsers.length > 0) {
@@ -168,12 +152,10 @@ async function handleDiscover(task, taskId, dependencies, config) {
168
152
  };
169
153
  await pubsub.topic(config.PUBSUB_TOPIC_USER_FETCH).publishMessage({ json: { tasks: [verificationTask] } });
170
154
  logger.log('SUCCESS', `${logPrefix} Chaining to 'verify' task for ${finalActiveUsers.length} active users.`);
171
- } else {
172
- logger.log('INFO', `${logPrefix} No active users found to verify.`);
173
- }
155
+ } else { logger.log('INFO', `${logPrefix} No active users found to verify.`); }
174
156
 
175
157
  } catch (err) {
176
- logger.log('ERROR', `${logPrefix} FATAL error processing discovery task.`, { errorMessage: err.message, errorStack: err.stack });
158
+ logger.log('ERROR', `${logPrefix} FATAL error processing discovery task.`, { errorMessage: err.message, errorStack: err.stack }); // We fucked up
177
159
  wasSuccess = false; // Ensure it's marked as failure
178
160
  } finally {
179
161
  if (selectedHeader && proxyUsed) {