bulltrackers-module 1.0.171 → 1.0.173

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,747 @@
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 { // For generic access of data types
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 []; // Handle empty portfolio
26
+
27
+ if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
28
+ return portfolio.PublicPositions || []; // SPECULATOR SCHEMA
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; // Handle empty position data
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; // Handle empty position data
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) { // Agnostic on user type, unused.
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) { // TODO - VERIFY THIS WORKS FOR SPECULATORS,
99
+ return position ? (position.Value || 0) : 0; // IS VALUE ACTUALLY THE VALUE OF POSITION AS A % OF TOTAL PORTFOLIO EQUITY? IS IT THE SAME FOR NORMAL USERS?
100
+ }
101
+
102
+ /**
103
+ * --- NEW PRIMITIVE ---
104
+ * Derives the approximate Entry Price of a position based on Current Price and Net Profit %.
105
+ * Formula: Entry = Current / (1 + (NetProfit / 100))
106
+ * @param {number} currentPrice - The current market price of the asset.
107
+ * @param {number} netProfitPct - The Net Profit percentage (e.g., -20.5).
108
+ * @returns {number} Estimated Entry Price.
109
+ */
110
+ static deriveEntryPrice(currentPrice, netProfitPct) {
111
+ if (!currentPrice || currentPrice <= 0) return 0;
112
+ // Avoid division by zero if P&L is -100% (unlikely but possible in crypto/options)
113
+ if (netProfitPct <= -100) return Number.MAX_SAFE_INTEGER; // Effectively infinite entry price (lost everything)
114
+ return currentPrice / (1 + (netProfitPct / 100.0));
115
+ }
116
+
117
+ // ========================================================================
118
+ // 4. PORTFOLIO LEVEL SUMMARY
119
+ // ========================================================================
120
+
121
+ /**
122
+ * Calculate/Extract Daily Portfolio P&L %.
123
+ */
124
+ static getPortfolioDailyPnl(portfolio, userType) {
125
+ if (!portfolio) return 0;
126
+
127
+ // 1. Speculator (Explicit 'NetProfit' field on root) ---> TODO : THIS IS FLAWED,
128
+ if (userType === SCHEMAS.USER_TYPES.SPECULATOR) { // SPECULATOR DATA DOES NOT CONTAIN THE ENTIRE PORTFOLIO DATA FOR THAT USER, IT CONTAINS A SNAPSHOT OF THE USERS' SPECIFIC ASSET WE REQUESTED FROM THE TASK ENGINE
129
+ // You cannot ever infer the entire speculator portfolio daily / weekly / monthly or any time period value based on the data we have for speculators
130
+ return portfolio.NetProfit || 0; // Data for speculators only works when we are looking at the ticker-specific data, or the trade history data which is agnostic on user type
131
+ }
132
+
133
+ // 2. Normal (Aggregated Calculation) ---> TODO : VERIFY THIS LOGIC, IT SHOULD BE OK
134
+ // Logic: Sum(Value - Invested) across the 'InstrumentType' breakdown
135
+ // This gives the net performance change for the day relative to the portfolio.
136
+ if (portfolio.AggregatedPositionsByInstrumentTypeID) {
137
+ return portfolio.AggregatedPositionsByInstrumentTypeID.reduce((sum, agg) => {
138
+ return sum + ((agg.Value || 0) - (agg.Invested || 0));
139
+ }, 0);
140
+ }
141
+
142
+ return 0;
143
+ }
144
+
145
+ // ========================================================================
146
+ // 5. TRADE DETAILS (SPECULATOR SPECIFIC)
147
+ // ========================================================================
148
+
149
+ static getDirection(position) {
150
+ if (!position) return "Buy";
151
+ if (position.Direction) return position.Direction;
152
+ if (typeof position.IsBuy === 'boolean') return position.IsBuy ? "Buy" : "Sell";
153
+ return "Buy"; // Default
154
+ }
155
+
156
+ static getLeverage(position) {
157
+ return position ? (position.Leverage || 1) : 1; // Default 1 IF NOT FOUND
158
+ }
159
+
160
+ static getOpenRate(position) {
161
+ return position ? (position.OpenRate || 0) : 0; // Default 0 IF NOT FOUND
162
+ }
163
+
164
+ static getCurrentRate(position) {
165
+ return position ? (position.CurrentRate || 0) : 0; // Default 0 IF NOT FOUND
166
+ }
167
+
168
+ static getStopLossRate(position) {
169
+ const rate = position ? (position.StopLossRate || 0) : 0;
170
+ // Fix for eToro bug: SL disabled positions can return 0.01 or similar small values.
171
+ // If the rate is positive but extremely small (<= 0.01), treat as 0 (disabled).
172
+ if (rate > 0 && rate <= 0.01) return 0; // Normalizes bug value to 0
173
+ if (rate < 0) return 0;
174
+ return rate;
175
+ }
176
+
177
+ static getTakeProfitRate(position) {
178
+ const rate = position ? (position.TakeProfitRate || 0) : 0;
179
+ // Fix for eToro bug: TP disabled positions can return INF or small values.
180
+ // If the rate is positive but extremely small (<= 0.01), treat as 0 (disabled).
181
+ if (rate > 0 && rate <= 0.01) return 0; // Normalizes bug value to 0
182
+ return rate;
183
+ }
184
+
185
+ static getHasTSL(position) {
186
+ return position ? (position.HasTrailingStopLoss === true) : false; // Default false IF NOT FOUND
187
+ }
188
+
189
+ /**
190
+ * Extract Open Date Time.
191
+ * Used for bagholder calculations.
192
+ */
193
+ static getOpenDateTime(position) {
194
+ if (!position || !position.OpenDateTime) return null;
195
+ return new Date(position.OpenDateTime);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * --- NEW CLASS: priceExtractor ---
201
+ * Handles schema-aware extraction of asset price history.
202
+ * Mitigates typo risk by centralizing access to context.prices.
203
+ */
204
+ class priceExtractor {
205
+ /**
206
+ * Retrieves the sorted price history for a specific instrument.
207
+ * @param {object} pricesContext - The global context.prices object.
208
+ * @param {string|number} tickerOrId - The Ticker or InstrumentID to fetch.
209
+ * @returns {Array<{date: string, price: number}>} Sorted array of price objects.
210
+ */
211
+ static getHistory(pricesContext, tickerOrId) {
212
+ if (!pricesContext || !pricesContext.history) return [];
213
+
214
+ // The history map is keyed by InstrumentID (e.g., "10000")
215
+ // We iterate to find the entry matching the request (by ID or Ticker)
216
+ // Optimization: If input is ID, direct lookup. If ticker, search.
217
+
218
+ let assetData = pricesContext.history[tickerOrId];
219
+
220
+ // Fallback: If direct lookup failed, maybe it's a ticker symbol?
221
+ if (!assetData) {
222
+ const id = Object.keys(pricesContext.history).find(key => {
223
+ const data = pricesContext.history[key];
224
+ return data.ticker === tickerOrId;
225
+ });
226
+ if (id) assetData = pricesContext.history[id];
227
+ }
228
+
229
+ if (!assetData || !assetData.prices) return [];
230
+
231
+ // Convert Map<Date, Price> to Array<{date, price}> and sort
232
+ const priceMap = assetData.prices;
233
+ const sortedDates = Object.keys(priceMap).sort((a, b) => a.localeCompare(b));
234
+
235
+ return sortedDates.map(date => ({
236
+ date: date,
237
+ price: priceMap[date]
238
+ })).filter(item => item.price > 0);
239
+ }
240
+
241
+ /**
242
+ * Returns all available assets with their histories.
243
+ * Useful for computations iterating over the entire market.
244
+ * @param {object} pricesContext - The global context.prices object.
245
+ * @returns {Map<string, Array<{date: string, price: number}>>} Map<Ticker, HistoryArray>
246
+ */
247
+ static getAllHistories(pricesContext) {
248
+ if (!pricesContext || !pricesContext.history) return new Map();
249
+
250
+ const results = new Map();
251
+ for (const [id, data] of Object.entries(pricesContext.history)) {
252
+ const ticker = data.ticker || id;
253
+ // Reuse the single-asset logic for consistency, though slightly less efficient
254
+ // than inlining the sort. For safety/DRY, we reuse.
255
+ const history = this.getHistory(pricesContext, id);
256
+ if (history.length > 0) {
257
+ results.set(ticker, history);
258
+ }
259
+ }
260
+ return results;
261
+ }
262
+ }
263
+
264
+ class HistoryExtractor {
265
+ // --- Schema Accessor (NEW) ---
266
+ /**
267
+ * Extracts the daily history snapshot from the User object.
268
+ * This decouples the computation from knowing 'user.history.today'.
269
+ */
270
+ static getDailyHistory(user) {
271
+ return user?.history?.today || null;
272
+ }
273
+
274
+ // --- Data Extractors ---
275
+ static getTradedAssets(historyDoc) {
276
+ if (!historyDoc || !Array.isArray(historyDoc.assets)) return [];
277
+ return historyDoc.assets;
278
+ }
279
+
280
+ static getInstrumentId(asset) {
281
+ return asset ? asset.instrumentId : null;
282
+ }
283
+
284
+ static getAvgHoldingTimeMinutes(asset) { // Note, in minutes, we could convert values here into hours or days but we leave as-is for now.
285
+ return asset ? (asset.avgHoldingTimeInMinutes || 0) : 0;
286
+ }
287
+
288
+ static getSummary(historyDoc) { // This returns the top-level summary of trade history
289
+ const all = historyDoc?.all;
290
+ if (!all) return null;
291
+
292
+ return { // The all object contains instrumentid of -1 value, we do not include this, it's a junk backend-eToro placeholder.
293
+ totalTrades: all.totalTrades || 0,
294
+ winRatio: all.winRatio || 0,
295
+ avgProfitPct: all.avgProfitPct || 0,
296
+ avgLossPct: all.avgLossPct || 0,
297
+ avgHoldingTimeInMinutes: all.avgHoldingTimeInMinutes || 0
298
+ };
299
+ }
300
+ }
301
+
302
+ class SignalPrimitives {
303
+ /**
304
+ * Safely extracts a specific numeric field for a specific ticker from a dependency.
305
+ */
306
+ static getMetric(dependencies, calcName, ticker, fieldName, fallback = 0) {
307
+ if (!dependencies || !dependencies[calcName]) return fallback;
308
+ const tickerData = dependencies[calcName][ticker];
309
+ if (!tickerData) return fallback;
310
+
311
+ const val = tickerData[fieldName];
312
+ return (typeof val === 'number') ? val : fallback;
313
+ }
314
+
315
+ /**
316
+ * Creates a unified Set of all keys (tickers) present across multiple dependency results.
317
+ */
318
+ static getUnionKeys(dependencies, calcNames) {
319
+ const keys = new Set();
320
+ if (!dependencies) return [];
321
+
322
+ for (const name of calcNames) {
323
+ const resultObj = dependencies[name];
324
+ if (resultObj && typeof resultObj === 'object') {
325
+ Object.keys(resultObj).forEach(k => keys.add(k));
326
+ }
327
+ }
328
+ return Array.from(keys);
329
+ }
330
+
331
+ /**
332
+ * Hyperbolic Tangent Normalization.
333
+ * Maps inputs to a strict -Scale to +Scale range.
334
+ */
335
+ static normalizeTanh(value, scale = 10, sensitivity = 10.0) { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/tanh
336
+ if (value === 0) return 0;
337
+ return Math.tanh(value / sensitivity) * scale;
338
+ }
339
+
340
+ /**
341
+ * Standard Z-Score normalization.
342
+ */
343
+ static normalizeZScore(value, mean, stdDev) { // https://gist.github.com/Restuta/5cbf1d186f17febe5e899febb63e1b86
344
+ if (!stdDev || stdDev === 0) return 0;
345
+ return (value - mean) / stdDev;
346
+ }
347
+
348
+ /**
349
+ * Simple Divergence (A - B).
350
+ */
351
+ static divergence(valueA, valueB) {
352
+ return (valueA || 0) - (valueB || 0);
353
+ }
354
+
355
+ static getPreviousState(previousComputed, calcName, ticker, fieldName = null) { // This is used for either fetching computations listed in getdependencies() OR self-history
356
+ if (!previousComputed || !previousComputed[calcName]) return null; // Using this for self-history DOES NOT cause a circular dependency because we assign a special rule in orchestration_helpers
357
+ // Which handles the self-reference, see 2. SMART SELF-FETCH in orchestration_helpers
358
+ const tickerData = previousComputed[calcName][ticker];
359
+ if (!tickerData) return null;
360
+
361
+ if (fieldName) {
362
+ return tickerData[fieldName];
363
+ }
364
+ return tickerData; // Return whole object (useful for _state)
365
+ }
366
+ }
367
+
368
+ class MathPrimitives {
369
+ static average(values) {
370
+ if (!values || !values.length) return 0;
371
+ return values.reduce((a, b) => a + b, 0) / values.length;
372
+ }
373
+
374
+ static median(values) {
375
+ if (!values || !values.length) return 0;
376
+ const sorted = [...values].sort((a, b) => a - b);
377
+ const mid = Math.floor(sorted.length / 2);
378
+ return sorted.length % 2 === 0
379
+ ? (sorted[mid - 1] + sorted[mid]) / 2
380
+ : sorted[mid];
381
+ }
382
+
383
+ static standardDeviation(values) {
384
+ if (!values || !values.length) return 0;
385
+ const avg = this.average(values);
386
+ const squareDiffs = values.map(val => Math.pow((val || 0) - avg, 2));
387
+ return Math.sqrt(this.average(squareDiffs));
388
+ }
389
+
390
+ static bucketBinary(value, threshold = 0) {
391
+ return value > threshold ? 'winner' : 'loser';
392
+ }
393
+
394
+ /**
395
+ * Calculates the probability of an asset hitting a specific price barrier (Stop Loss/Take Profit)
396
+ * within a given timeframe using the First Passage Time of Geometric Brownian Motion.
397
+ * * Formula: P(T < t) = Φ((b - vt) / σ√t) + exp(2vb/σ²) * Φ((b + vt) / σ√t)
398
+ * Where:
399
+ * b = ln(Barrier/Price)
400
+ * v = drift - 0.5 * volatility^2
401
+ * * @param {number} currentPrice - The current price of the asset
402
+ * @param {number} barrierPrice - The target price (SL or TP)
403
+ * @param {number} volatility - Annualized volatility (e.g., 0.40 for 40%)
404
+ * @param {number} days - Number of days to forecast (e.g., 3)
405
+ * @param {number} drift - (Optional) Annualized drift. Default 0 (Risk Neutral).
406
+ * @returns {number} Probability (0.0 to 1.0)
407
+ */
408
+ static calculateHitProbability(currentPrice, barrierPrice, volatility, days, drift = 0) { // https://www.ma.ic.ac.uk/~bin06/M3A22/m3f22chVII.pdf
409
+ if (currentPrice <= 0 || barrierPrice <= 0 || volatility <= 0 || days <= 0) return 0;
410
+
411
+ const t = days / 365.0; // Convert days to years
412
+ const sigma = volatility;
413
+ const mu = drift;
414
+
415
+ // The barrier in log-space
416
+ const b = Math.log(barrierPrice / currentPrice);
417
+
418
+ // Adjusted drift (nu)
419
+ const nu = mu - 0.5 * Math.pow(sigma, 2);
420
+
421
+ const sqrtT = Math.sqrt(t);
422
+ const sigmaSqrtT = sigma * sqrtT;
423
+
424
+ // Helper for Standard Normal CDF (Φ)
425
+ const normCDF = (x) => {
426
+ const t = 1 / (1 + 0.2316419 * Math.abs(x));
427
+ const d = 0.3989423 * Math.exp(-x * x / 2);
428
+ const prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
429
+ return x > 0 ? 1 - prob : prob;
430
+ };
431
+
432
+ // Standard First Passage Time Formula parts
433
+ const term1 = (b - nu * t) / sigmaSqrtT;
434
+ const term2 = (2 * nu * b) / (sigma * sigma);
435
+ const term3 = (b + nu * t) / sigmaSqrtT;
436
+
437
+ // If barrier is below price (Stop Loss for Long), b is negative.
438
+ // If barrier is above price (Take Profit for Long), we flip the logic essentially,
439
+ // but the formula works for the distance.
440
+ // However, for strict GBM hitting time, we usually treat 'b' as the distance.
441
+ // For this implementation, we check direction relative to barrier.
442
+
443
+ // If we are already at or past the barrier, probability is 100%
444
+ if ((currentPrice > barrierPrice && barrierPrice > currentPrice) ||
445
+ (currentPrice < barrierPrice && barrierPrice < currentPrice)) {
446
+ return 1.0;
447
+ }
448
+
449
+ // Calculate Probability
450
+ // Note: If nu is 0, the second term simplifies significantly, but we keep full form.
451
+ const probability = normCDF(( -Math.abs(b) - nu * t ) / sigmaSqrtT) +
452
+ Math.exp((2 * nu * Math.abs(b)) / (sigma * sigma)) * normCDF(( -Math.abs(b) + nu * t ) / sigmaSqrtT);
453
+
454
+ return Math.min(Math.max(probability, 0), 1);
455
+ }
456
+
457
+ /**
458
+ * --- NEW PRIMITIVE ---
459
+ * Simulates future price paths using Geometric Brownian Motion (Monte Carlo).
460
+ * Used for testing portfolio resilience against potential market moves.
461
+ * @param {number} currentPrice - S0
462
+ * @param {number} volatility - Annualized volatility (sigma)
463
+ * @param {number} days - Time horizon in days (t)
464
+ * @param {number} simulations - Number of paths to generate (e.g., 1000)
465
+ * @param {number} drift - Annualized drift (mu), default 0
466
+ * @returns {Float32Array} Array of simulated end prices
467
+ */
468
+ static simulateGBM(currentPrice, volatility, days, simulations = 1000, drift = 0) {
469
+ if (currentPrice <= 0 || volatility <= 0 || days <= 0) return new Float32Array(0);
470
+
471
+ const t = days / 365.0;
472
+ const sigma = volatility;
473
+ const mu = drift;
474
+ const driftTerm = (mu - 0.5 * sigma * sigma) * t;
475
+ const volTerm = sigma * Math.sqrt(t);
476
+
477
+ // Use Float32Array for memory efficiency with large simulation counts
478
+ const results = new Float32Array(simulations);
479
+
480
+ for (let i = 0; i < simulations; i++) {
481
+ // Box-Muller transform for efficient standard normal distribution generation
482
+ const u1 = Math.random();
483
+ const u2 = Math.random();
484
+ const z = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
485
+
486
+ // GBM Formula: St = S0 * exp((mu - 0.5*sigma^2)t + sigma*Wt)
487
+ results[i] = currentPrice * Math.exp(driftTerm + volTerm * z);
488
+ }
489
+ return results;
490
+ }
491
+
492
+ /**
493
+ * --- NEW PRIMITIVE ---
494
+ * Simulates "Population Breakdown" (Capitulation) Risk.
495
+ * Correlates simulated price drops with user pain thresholds.
496
+ * @param {Float32Array} pricePaths - Array of simulated prices (from simulateGBM).
497
+ * @param {Array<{entryPrice: number, thresholdPct: number}>} userProfiles - Array of user states.
498
+ * @returns {number} Expected % of population that capitulates (0.0 - 1.0).
499
+ */
500
+ static simulatePopulationBreakdown(pricePaths, userProfiles) {
501
+ if (!pricePaths.length || !userProfiles.length) return 0;
502
+
503
+ let totalBreakdownEvents = 0;
504
+ const totalSims = pricePaths.length;
505
+ const totalUsers = userProfiles.length;
506
+
507
+ // For each simulated future price scenario...
508
+ for (let i = 0; i < totalSims; i++) {
509
+ const simPrice = pricePaths[i];
510
+ let capitulatedUsersInScenario = 0;
511
+
512
+ // ...check every user to see if they survive
513
+ for (let j = 0; j < totalUsers; j++) {
514
+ const user = userProfiles[j];
515
+ // Calculate hypothetical P&L for this user in this scenario
516
+ // P&L% = (CurrentValue - EntryValue) / EntryValue
517
+ const hypotheticalPnL = ((simPrice - user.entryPrice) / user.entryPrice) * 100;
518
+
519
+ // If hypothetical P&L is worse (lower) than their historical pain threshold, they capitulate.
520
+ // Note: thresholdPct is typically negative (e.g., -15.0)
521
+ if (hypotheticalPnL < user.thresholdPct) {
522
+ capitulatedUsersInScenario++;
523
+ }
524
+ }
525
+
526
+ // Add the % of users who broke in this scenario to the accumulator
527
+ totalBreakdownEvents += (capitulatedUsersInScenario / totalUsers);
528
+ }
529
+
530
+ // Return the average capitulation rate across all simulations
531
+ return totalBreakdownEvents / totalSims;
532
+ }
533
+ }
534
+
535
+ class Aggregators {
536
+ /**
537
+ * Helper to bucket users by P&L Status.
538
+ * Used by legacy systems or specific aggregators.
539
+ */
540
+ static bucketUsersByPnlPerAsset(usersData, tickerMap) { // https://www.geeksforgeeks.org/javascript/bucket-sort-visualization-using-javascript/
541
+ const buckets = new Map();
542
+ for (const [userId, portfolio] of Object.entries(usersData)) {
543
+ // Auto-detect type if not provided (Legacy compatibility) TODO : We do not need legacy compatability, legacy computations do not run.
544
+ const userType = portfolio.PublicPositions ? SCHEMAS.USER_TYPES.SPECULATOR : SCHEMAS.USER_TYPES.NORMAL;
545
+ const positions = DataExtractor.getPositions(portfolio, userType);
546
+
547
+ for (const pos of positions) {
548
+ const id = DataExtractor.getInstrumentId(pos);
549
+ const pnl = DataExtractor.getNetProfit(pos);
550
+ if (!id || pnl === 0) continue;
551
+
552
+ const ticker = tickerMap[id];
553
+ if (!ticker) continue;
554
+
555
+ if (!buckets.has(ticker)) buckets.set(ticker, { winners: [], losers: [] });
556
+ const b = buckets.get(ticker);
557
+
558
+ if (pnl > 0) b.winners.push(userId);
559
+ else b.losers.push(userId);
560
+ }
561
+ }
562
+ return Object.fromEntries(buckets);
563
+ }
564
+
565
+ /**
566
+ * --- NEW PRIMITIVE ---
567
+ * Calculates Weighted Sentiment (Avg P&L) for a set of positions.
568
+ * Solves "Dirty Data" / Variable N issue by weighting by investment size.
569
+ * @param {Array} positions - Array of raw position objects.
570
+ * @returns {number} Weighted Average NetProfit %.
571
+ */
572
+ static getWeightedSentiment(positions) {
573
+ if (!positions || positions.length === 0) return 0;
574
+
575
+ let totalWeightedPnL = 0;
576
+ let totalWeight = 0;
577
+
578
+ for (const pos of positions) {
579
+ // Use DataExtractor to be safe and schema-agnostic
580
+ const pnl = DataExtractor.getNetProfit(pos);
581
+ const weight = DataExtractor.getPositionWeight(pos); // 'Invested' or 'Amount'
582
+
583
+ if (weight > 0) {
584
+ totalWeightedPnL += (pnl * weight);
585
+ totalWeight += weight;
586
+ }
587
+ }
588
+
589
+ if (totalWeight === 0) return 0;
590
+ return totalWeightedPnL / totalWeight;
591
+ }
592
+ }
593
+
594
+ // Validation layer -- Used to validate the data incoming
595
+ class Validators {
596
+ static validatePortfolio(portfolio, userType) {
597
+ if (!portfolio) return { valid: false, errors: ['Portfolio is null'] };
598
+
599
+ // Handle both types of schema
600
+ if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
601
+ if (!portfolio.PublicPositions) return { valid: false, errors: ['Missing PublicPositions'] };
602
+ } else {
603
+ if (!portfolio.AggregatedPositions) return { valid: false, errors: ['Missing AggregatedPositions'] };
604
+ }
605
+
606
+ return { valid: true, errors: [] };
607
+ }
608
+ }
609
+
610
+ class TimeSeries {
611
+ /**
612
+ * Updates a Rolling Mean and Variance using Welford's Online Algorithm (EMA variant). // https://jonisalonen.com/2013/deriving-welfords-method-for-computing-variance/
613
+ * @param {number} value - New data point.
614
+ * @param {Object} state - { mean: number, variance: number }
615
+ * @param {number} alpha - Decay factor (e.g., 0.1 for ~20 days).
616
+ */
617
+ static updateEMAState(value, state, alpha = 0.1) {
618
+ const mean = state ? (state.mean || 0) : 0;
619
+ const variance = state ? (state.variance || 1) : 1; // Default variance 1 to avoid div/0
620
+
621
+ if (value === undefined || value === null || isNaN(value)) {
622
+ return { mean, variance };
623
+ }
624
+
625
+ // EMA Update for Mean
626
+ const diff = value - mean;
627
+ const newMean = mean + (alpha * diff);
628
+
629
+ // EMA Update for Variance
630
+ const newVariance = (1 - alpha) * (variance + (alpha * diff * diff));
631
+
632
+ return { mean: newMean, variance: newVariance };
633
+ }
634
+
635
+ /**
636
+ * Calculates Pearson Correlation between two arrays. https://gist.github.com/matt-west/6500993
637
+ */
638
+ static pearsonCorrelation(x, y) {
639
+ if (!x || !y || x.length !== y.length || x.length === 0) return 0;
640
+
641
+ const n = x.length;
642
+ // Simple sums
643
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
644
+
645
+ for (let i = 0; i < n; i++) {
646
+ sumX += x[i];
647
+ sumY += y[i];
648
+ sumXY += x[i] * y[i];
649
+ sumX2 += x[i] * x[i];
650
+ sumY2 += y[i] * y[i];
651
+ }
652
+
653
+ const numerator = (n * sumXY) - (sumX * sumY);
654
+ const denominator = Math.sqrt(((n * sumX2) - (sumX * sumX)) * ((n * sumY2) - (sumY * sumY)));
655
+
656
+ return (denominator === 0) ? 0 : numerator / denominator;
657
+ }
658
+ }
659
+
660
+
661
+ class DistributionAnalytics {
662
+ /**
663
+ * Gaussian Kernel Density Estimation (KDE)
664
+ * Converts discrete price points into a continuous probability density curve.
665
+ * Optimized for memory: accepts pre-binned data.
666
+ * @param {Array<{value: number, weight: number}>} data - Points or Bins.
667
+ * @param {number} bandwidth - Smoothing factor (h).
668
+ * @param {number} steps - Resolution of the output curve.
669
+ */
670
+ static computeKDE(data, bandwidth, steps = 60) {
671
+ if (!data || data.length === 0) return [];
672
+
673
+ let min = Infinity, max = -Infinity;
674
+ for (const p of data) {
675
+ if (p.value < min) min = p.value;
676
+ if (p.value > max) max = p.value;
677
+ }
678
+
679
+ // Pad range to capture tails
680
+ min -= bandwidth * 3;
681
+ max += bandwidth * 3;
682
+ const stepSize = (max - min) / steps;
683
+ const curve = [];
684
+
685
+ for (let i = 0; i <= steps; i++) {
686
+ const x = min + (i * stepSize);
687
+ let density = 0;
688
+ // Vectorized-like summation https://cvw.cac.cornell.edu/vector/intro/how-vector-works#:~:text=Vectorization%20is%20a%20process%20by,performance%20increases%20obtained%20by%20vectorization.
689
+ for (const p of data) {
690
+ const diff = (x - p.value);
691
+ // Optimization: Skip calculation for points too far away (> 3 std devs)
692
+ if (Math.abs(diff) > bandwidth * 3) continue;
693
+
694
+ const u = diff / bandwidth;
695
+ const k = 0.39894228 * Math.exp(-0.5 * u * u); // Standard Normal PDF
696
+ density += (p.weight * k) / bandwidth;
697
+ }
698
+ if (density > 0) curve.push({ price: x, density });
699
+ }
700
+ return curve;
701
+ }
702
+
703
+ static integrateProfile(curve, startPrice, endPrice) {
704
+ let sum = 0;
705
+ for (let i = 0; i < curve.length - 1; i++) {
706
+ const p1 = curve[i];
707
+ const p2 = curve[i+1];
708
+ if (p1.price >= startPrice && p2.price <= endPrice) {
709
+ // Trapezoidal Rule https://www.khanacademy.org/math/ap-calculus-ab/ab-integration-new/ab-6-2/a/understanding-the-trapezoid-rule
710
+ sum += (p2.price - p1.price) * ((p1.density + p2.density) / 2);
711
+ }
712
+ }
713
+ return sum;
714
+ }
715
+
716
+ static linearRegression(xValues, yValues) { // https://hazlo.medium.com/linear-regression-from-scratch-in-js-first-foray-into-ml-for-web-developers-867cfcae8fde
717
+ const n = xValues.length;
718
+ if (n !== yValues.length || n < 2) return { slope: 0, r2: 0 };
719
+
720
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0, sumYY = 0;
721
+ for (let i = 0; i < n; i++) {
722
+ sumX += xValues[i];
723
+ sumY += yValues[i];
724
+ sumXY += xValues[i] * yValues[i];
725
+ sumXX += xValues[i] * xValues[i];
726
+ sumYY += yValues[i] * yValues[i];
727
+ }
728
+
729
+ const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
730
+ return { slope, n };
731
+ }
732
+ }
733
+
734
+
735
+ // This block is dynamically generated. Do not edit manually.
736
+ module.exports = {
737
+ Aggregators,
738
+ DataExtractor,
739
+ DistributionAnalytics,
740
+ HistoryExtractor,
741
+ MathPrimitives,
742
+ SCHEMAS,
743
+ SignalPrimitives,
744
+ TimeSeries,
745
+ Validators,
746
+ priceExtractor
747
+ };