bulltrackers-module 1.0.212 → 1.0.214
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/functions/computation-system/helpers/computation_manifest_builder.js +53 -65
- package/functions/computation-system/helpers/computation_pass_runner.js +29 -46
- package/functions/computation-system/layers/extractors.js +95 -231
- package/functions/task-engine/helpers/update_helpers.js +17 -49
- package/package.json +1 -1
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview
|
|
3
|
-
* Dynamic Manifest Builder (
|
|
3
|
+
* Dynamic Manifest Builder (v6 - Merkle Tree Dependency Hashing)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* * SAFE MODE:
|
|
13
|
-
* If a calculation uses no detectable layer keywords, it defaults to
|
|
14
|
-
* depending on ALL layers to prevent staleness.
|
|
5
|
+
* KEY FEATURES:
|
|
6
|
+
* 1. Smart Layer Hashing: Detects used layers (Math, Extractors) to avoid stale helper code.
|
|
7
|
+
* 2. Cascading Invalidation (Merkle Hashing):
|
|
8
|
+
* The final hash of a computation is derived from:
|
|
9
|
+
* [Own Code] + [Layer States] + [Hashes of all Dependencies]
|
|
10
|
+
* * This guarantees that if Calculation A is updated, Calculation B (which depends on A)
|
|
11
|
+
* will automatically generate a new hash, forcing the system to re-run it.
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
14
|
const { generateCodeHash } = require('../utils/utils');
|
|
18
15
|
|
|
19
16
|
// 1. Import Layers directly to generate their "State Hashes"
|
|
20
|
-
// We import them individually to hash them as distinct domains.
|
|
21
17
|
const MathematicsLayer = require('../layers/mathematics');
|
|
22
18
|
const ExtractorsLayer = require('../layers/extractors');
|
|
23
19
|
const ProfilingLayer = require('../layers/profiling');
|
|
@@ -27,10 +23,6 @@ const ValidatorsLayer = require('../layers/validators');
|
|
|
27
23
|
* 1. Layer Hash Generation
|
|
28
24
|
* -------------------------------------------------- */
|
|
29
25
|
|
|
30
|
-
/**
|
|
31
|
-
* Generates a single hash representing the entire state of a layer module.
|
|
32
|
-
* Combines all exported classes/functions/objects into one deterministic string.
|
|
33
|
-
*/
|
|
34
26
|
function generateLayerHash(layerExports, layerName) {
|
|
35
27
|
const keys = Object.keys(layerExports).sort(); // Sort for determinism
|
|
36
28
|
let combinedSource = `LAYER:${layerName}`;
|
|
@@ -57,21 +49,20 @@ const LAYER_HASHES = {
|
|
|
57
49
|
};
|
|
58
50
|
|
|
59
51
|
// Map code patterns to Layer dependencies
|
|
60
|
-
// If a calculation's code contains these strings, it depends on that layer.
|
|
61
52
|
const LAYER_TRIGGERS = {
|
|
62
53
|
'mathematics': [
|
|
63
54
|
'math.compute', 'MathPrimitives',
|
|
64
|
-
'math.signals', 'SignalPrimitives',
|
|
55
|
+
'math.signals', 'SignalPrimitives', 'signals.',
|
|
65
56
|
'math.aggregate', 'Aggregators',
|
|
66
|
-
'math.timeseries', 'TimeSeries',
|
|
67
|
-
'math.distribution', 'DistributionAnalytics',
|
|
57
|
+
'math.timeseries', 'TimeSeries', 'timeSeries.',
|
|
58
|
+
'math.distribution', 'DistributionAnalytics', 'distribution.',
|
|
68
59
|
'math.financial', 'FinancialEngineering'
|
|
69
60
|
],
|
|
70
61
|
'extractors': [
|
|
71
62
|
'math.extract', 'DataExtractor',
|
|
72
63
|
'math.history', 'HistoryExtractor',
|
|
73
64
|
'math.prices', 'priceExtractor',
|
|
74
|
-
'math.insights', 'InsightsExtractor',
|
|
65
|
+
'math.insights', 'InsightsExtractor', 'insights.',
|
|
75
66
|
'math.tradeSeries', 'TradeSeriesBuilder'
|
|
76
67
|
],
|
|
77
68
|
'profiling': [
|
|
@@ -121,18 +112,6 @@ function suggestClosest(name, candidates, n = 3) {
|
|
|
121
112
|
return scores.slice(0, n).map(s => s[0]);
|
|
122
113
|
}
|
|
123
114
|
|
|
124
|
-
function findCycles(manifestMap, adjacencyList) {
|
|
125
|
-
const visited = new Set(), stack = new Set(), cycles = [];
|
|
126
|
-
const dfs = (node, path) => {
|
|
127
|
-
if (stack.has(node)) { const idx = path.indexOf(node); cycles.push([...path.slice(idx), node]); return; }
|
|
128
|
-
if (visited.has(node)) return;
|
|
129
|
-
visited.add(node); stack.add(node);
|
|
130
|
-
for (const nb of adjacencyList.get(node) || []) dfs(nb, [...path, nb]);
|
|
131
|
-
stack.delete(node); };
|
|
132
|
-
for (const name of manifestMap.keys()) dfs(name, [name]);
|
|
133
|
-
return cycles;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
115
|
function getDependencySet(endpoints, adjacencyList) {
|
|
137
116
|
const required = new Set(endpoints);
|
|
138
117
|
const queue = [...endpoints];
|
|
@@ -146,24 +125,21 @@ function getDependencySet(endpoints, adjacencyList) {
|
|
|
146
125
|
* -------------------------------------------------- */
|
|
147
126
|
|
|
148
127
|
function buildManifest(productLinesToRun = [], calculations) {
|
|
149
|
-
log.divider('Building Dynamic Manifest (
|
|
128
|
+
log.divider('Building Dynamic Manifest (Merkle Hashing)');
|
|
150
129
|
log.info(`Target Product Lines: [${productLinesToRun.join(', ')}]`);
|
|
151
|
-
|
|
152
|
-
|
|
130
|
+
|
|
153
131
|
const manifestMap = new Map();
|
|
154
132
|
const adjacency = new Map();
|
|
155
133
|
const reverseAdjacency = new Map();
|
|
156
134
|
const inDegree = new Map();
|
|
157
135
|
let hasFatalError = false;
|
|
158
136
|
|
|
159
|
-
/* ---------------- 1. Load All Calculations ---------------- */
|
|
137
|
+
/* ---------------- 1. Load All Calculations (Phase 1: Intrinsic Hash) ---------------- */
|
|
160
138
|
log.step('Loading and validating all calculation classes…');
|
|
161
|
-
const allCalculationClasses = new Map();
|
|
162
139
|
|
|
163
140
|
function processCalc(Class, name, folderName) {
|
|
164
141
|
if (!Class || typeof Class !== 'function') return;
|
|
165
142
|
const normalizedName = normalizeName(name);
|
|
166
|
-
allCalculationClasses.set(normalizedName, Class);
|
|
167
143
|
|
|
168
144
|
if (typeof Class.getMetadata !== 'function') { log.fatal(`Calculation "${normalizedName}" is missing static getMetadata().`); hasFatalError = true; return; }
|
|
169
145
|
if (typeof Class.getDependencies !== 'function') { log.fatal(`Calculation "${normalizedName}" is missing static getDependencies().`); hasFatalError = true;return; }
|
|
@@ -171,46 +147,34 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
171
147
|
const metadata = Class.getMetadata();
|
|
172
148
|
const dependencies = Class.getDependencies().map(normalizeName);
|
|
173
149
|
|
|
174
|
-
|
|
175
|
-
|
|
150
|
+
const codeStr = Class.toString();
|
|
151
|
+
if (metadata.isHistorical === true && !codeStr.includes('yesterday') && !codeStr.includes('previousComputed')) {
|
|
152
|
+
log.warn(`Calculation "${normalizedName}" marked 'isHistorical' but no 'previousComputed' state reference found.`);
|
|
176
153
|
}
|
|
177
154
|
|
|
178
|
-
let finalCategory = folderName;
|
|
179
|
-
if (folderName === 'core') {
|
|
180
|
-
if (metadata.category) finalCategory = metadata.category;
|
|
181
|
-
} else {
|
|
182
|
-
finalCategory = folderName;
|
|
183
|
-
}
|
|
155
|
+
let finalCategory = folderName === 'core' && metadata.category ? metadata.category : folderName;
|
|
184
156
|
|
|
185
|
-
// ---
|
|
186
|
-
|
|
187
|
-
let compositeHashString = generateCodeHash(
|
|
157
|
+
// --- PHASE 1: INTRINSIC HASH (Code + Layers) ---
|
|
158
|
+
// We do NOT include dependencies yet.
|
|
159
|
+
let compositeHashString = generateCodeHash(codeStr);
|
|
188
160
|
const usedLayers = [];
|
|
189
161
|
|
|
190
|
-
//
|
|
162
|
+
// Check for specific layer usage
|
|
191
163
|
for (const [layerName, triggers] of Object.entries(LAYER_TRIGGERS)) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
compositeHashString += LAYER_HASHES[layerName]; // Append Layer Hash
|
|
164
|
+
if (triggers.some(trigger => codeStr.includes(trigger))) {
|
|
165
|
+
compositeHashString += LAYER_HASHES[layerName];
|
|
195
166
|
usedLayers.push(layerName);
|
|
196
167
|
}
|
|
197
168
|
}
|
|
198
169
|
|
|
199
|
-
//
|
|
200
|
-
// If we found NO specific triggers (e.g. legacy code or unique structure),
|
|
201
|
-
// assume it might use anything. Depend on ALL layers to be safe.
|
|
170
|
+
// Safe Mode Fallback
|
|
202
171
|
let isSafeMode = false;
|
|
203
172
|
if (usedLayers.length === 0) {
|
|
204
173
|
isSafeMode = true;
|
|
205
174
|
Object.values(LAYER_HASHES).forEach(h => compositeHashString += h);
|
|
206
175
|
}
|
|
207
176
|
|
|
208
|
-
|
|
209
|
-
const finalHash = generateCodeHash(compositeHashString);
|
|
210
|
-
|
|
211
|
-
if (isSafeMode) {
|
|
212
|
-
log.warn(`[Hash] ${normalizedName}: No specific dependencies detected. Enforcing SAFE MODE (Linked to ALL Layers).`);
|
|
213
|
-
}
|
|
177
|
+
const baseHash = generateCodeHash(compositeHashString);
|
|
214
178
|
|
|
215
179
|
const manifestEntry = {
|
|
216
180
|
name: normalizedName,
|
|
@@ -223,7 +187,7 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
223
187
|
userType: metadata.userType,
|
|
224
188
|
dependencies: dependencies,
|
|
225
189
|
pass: 0,
|
|
226
|
-
hash:
|
|
190
|
+
hash: baseHash, // Intrinsic Hash (Updated later to include deps)
|
|
227
191
|
debugUsedLayers: isSafeMode ? ['ALL (Safe Mode)'] : usedLayers
|
|
228
192
|
};
|
|
229
193
|
|
|
@@ -237,7 +201,6 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
237
201
|
throw new Error('Manifest build failed: Invalid calculations object.');
|
|
238
202
|
}
|
|
239
203
|
|
|
240
|
-
// Iterate over folders (folderName becomes the default category)
|
|
241
204
|
for (const folderName in calculations) {
|
|
242
205
|
if (folderName === 'legacy') continue;
|
|
243
206
|
const group = calculations[folderName];
|
|
@@ -308,6 +271,31 @@ function buildManifest(productLinesToRun = [], calculations) {
|
|
|
308
271
|
if (sortedManifest.length !== filteredManifestMap.size) {
|
|
309
272
|
throw new Error('Circular dependency detected. Manifest build failed.'); }
|
|
310
273
|
|
|
274
|
+
/* ---------------- 5. Phase 2: Cascading Dependency Hashing ---------------- */
|
|
275
|
+
// Now that we have a topological order (Dependencies come BEFORE Consumers),
|
|
276
|
+
// we can update hashes sequentially.
|
|
277
|
+
log.step('Computing Cascading Merkle Hashes...');
|
|
278
|
+
|
|
279
|
+
for (const entry of sortedManifest) {
|
|
280
|
+
// Start with the intrinsic hash (Code + Layers)
|
|
281
|
+
let dependencySignature = entry.hash;
|
|
282
|
+
|
|
283
|
+
// Append the hashes of all dependencies
|
|
284
|
+
// Since we are iterating in topo order, dependencies are guaranteed to be processed/updated already.
|
|
285
|
+
if (entry.dependencies && entry.dependencies.length > 0) {
|
|
286
|
+
const depHashes = entry.dependencies.map(depName => {
|
|
287
|
+
const depEntry = filteredManifestMap.get(depName);
|
|
288
|
+
if (!depEntry) return ''; // Should not happen given validation
|
|
289
|
+
return depEntry.hash;
|
|
290
|
+
}).join('|'); // Use separator to prevent collisions
|
|
291
|
+
|
|
292
|
+
dependencySignature += `|DEPS:${depHashes}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Generate the Final Smart Hash
|
|
296
|
+
entry.hash = generateCodeHash(dependencySignature);
|
|
297
|
+
}
|
|
298
|
+
|
|
311
299
|
log.success(`Total passes required: ${maxPass}`);
|
|
312
300
|
return sortedManifest;
|
|
313
301
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FILENAME: bulltrackers-module/functions/computation-system/helpers/computation_pass_runner.js
|
|
3
|
-
* FIXED: '
|
|
4
|
-
* UPDATED: Uses Code Hash for smart versioning and invalidation.
|
|
3
|
+
* FIXED: 'storedStatus.substring' crash and 'missing dependency' log clarity.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
const {
|
|
@@ -20,10 +19,6 @@ const { getExpectedDateStrings, normalizeName } = require('../utils/utils.js');
|
|
|
20
19
|
|
|
21
20
|
const PARALLEL_BATCH_SIZE = 7;
|
|
22
21
|
|
|
23
|
-
/**
|
|
24
|
-
* LEGACY / MANUAL RUNNER
|
|
25
|
-
* (Kept for backward compatibility if you run the old HTTP endpoint directly)
|
|
26
|
-
*/
|
|
27
22
|
async function runComputationPass(config, dependencies, computationManifest) {
|
|
28
23
|
const { logger } = dependencies;
|
|
29
24
|
const passToRun = String(config.COMPUTATION_PASS_TO_RUN);
|
|
@@ -31,7 +26,6 @@ async function runComputationPass(config, dependencies, computationManifest) {
|
|
|
31
26
|
|
|
32
27
|
logger.log('INFO', `🚀 Starting PASS ${passToRun} (Legacy Mode)...`);
|
|
33
28
|
|
|
34
|
-
// Hardcoded earliest dates
|
|
35
29
|
const earliestDates = {
|
|
36
30
|
portfolio: new Date('2025-09-25T00:00:00Z'),
|
|
37
31
|
history: new Date('2025-11-05T00:00:00Z'),
|
|
@@ -51,13 +45,12 @@ async function runComputationPass(config, dependencies, computationManifest) {
|
|
|
51
45
|
const endDateUTC = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate() - 1));
|
|
52
46
|
const allExpectedDates = getExpectedDateStrings(passEarliestDate, endDateUTC);
|
|
53
47
|
|
|
54
|
-
// Legacy Batch Optimization for Price (Only used in legacy loop)
|
|
55
48
|
const priceBatchCalcs = calcsInThisPass.filter(c => c.type === 'meta' && c.rootDataDependencies?.includes('price'));
|
|
56
49
|
const standardAndOtherMetaCalcs = calcsInThisPass.filter(c => !priceBatchCalcs.includes(c));
|
|
57
50
|
|
|
58
51
|
if (priceBatchCalcs.length > 0) {
|
|
59
52
|
try {
|
|
60
|
-
await runBatchPriceComputation(config, dependencies, allExpectedDates, priceBatchCalcs);
|
|
53
|
+
await runBatchPriceComputation(config, dependencies, allExpectedDates, priceBatchCalcs);
|
|
61
54
|
} catch (e) { logger.log('ERROR', 'Legacy Batch Price failed', e); }
|
|
62
55
|
}
|
|
63
56
|
|
|
@@ -69,61 +62,56 @@ async function runComputationPass(config, dependencies, computationManifest) {
|
|
|
69
62
|
}
|
|
70
63
|
}
|
|
71
64
|
|
|
72
|
-
/**
|
|
73
|
-
* UPDATED: Isolated function to run computations for a single date.
|
|
74
|
-
* Uses Code Hash to determine if a re-run is necessary.
|
|
75
|
-
*/
|
|
76
65
|
async function runDateComputation(dateStr, passToRun, calcsInThisPass, config, dependencies, computationManifest) {
|
|
77
66
|
const { logger } = dependencies;
|
|
78
67
|
const dateToProcess = new Date(dateStr + 'T00:00:00Z');
|
|
79
68
|
|
|
80
|
-
// 1. Fetch Status for THIS specific date only
|
|
81
69
|
const dailyStatus = await fetchComputationStatus(dateStr, config, dependencies);
|
|
82
70
|
|
|
83
|
-
//
|
|
84
|
-
const
|
|
71
|
+
// Filter AND Log reason for skipping
|
|
72
|
+
const calcsToAttempt = [];
|
|
73
|
+
|
|
74
|
+
for (const calc of calcsInThisPass) {
|
|
85
75
|
const cName = normalizeName(calc.name);
|
|
86
76
|
const storedStatus = dailyStatus[cName];
|
|
87
77
|
const currentHash = calc.hash;
|
|
88
78
|
|
|
89
|
-
//
|
|
90
|
-
// 'Complete' means truthy (hash or true)
|
|
79
|
+
// 1. Dependency Check
|
|
91
80
|
if (calc.dependencies && calc.dependencies.length > 0) {
|
|
92
|
-
const missing = calc.dependencies.filter(depName =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
81
|
+
const missing = calc.dependencies.filter(depName => !dailyStatus[normalizeName(depName)]);
|
|
82
|
+
if (missing.length > 0) {
|
|
83
|
+
// Too noisy to log every skip, but useful for debugging if needed.
|
|
84
|
+
// Only logging if it's NOT a bulk skip.
|
|
85
|
+
// logger.log('TRACE', `[Skip] ${cName} missing deps: ${missing.join(', ')}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
97
88
|
}
|
|
98
89
|
|
|
99
|
-
// Logic A: No previous run
|
|
90
|
+
// 2. Logic A: No previous run
|
|
100
91
|
if (!storedStatus) {
|
|
101
92
|
logger.log('INFO', `[Versioning] ${cName}: New run needed (No prior status).`);
|
|
102
|
-
|
|
93
|
+
calcsToAttempt.push(calc);
|
|
94
|
+
continue;
|
|
103
95
|
}
|
|
104
96
|
|
|
105
|
-
// Logic B: Hash Mismatch
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
// 3. Logic B: Hash Mismatch
|
|
98
|
+
// FIX: Ensure storedStatus is a string before calling substring
|
|
99
|
+
if (typeof storedStatus === 'string' && currentHash && storedStatus !== currentHash) {
|
|
100
|
+
logger.log('INFO', `[Versioning] ${cName}: Code Changed. (Old: ${storedStatus.substring(0,6)}... New: ${currentHash.substring(0,6)}...)`);
|
|
101
|
+
calcsToAttempt.push(calc);
|
|
102
|
+
continue;
|
|
109
103
|
}
|
|
110
104
|
|
|
111
|
-
// Logic C: Legacy
|
|
112
|
-
// If stored is strictly boolean true, but we have a hash, we upgrade (re-run) to stamp the hash.
|
|
105
|
+
// 4. Logic C: Upgrade Legacy Boolean -> Hash
|
|
113
106
|
if (storedStatus === true && currentHash) {
|
|
114
|
-
logger.log('INFO', `[Versioning] ${cName}: Upgrading legacy status to Hash
|
|
115
|
-
|
|
107
|
+
logger.log('INFO', `[Versioning] ${cName}: Upgrading legacy status to Hash.`);
|
|
108
|
+
calcsToAttempt.push(calc);
|
|
109
|
+
continue;
|
|
116
110
|
}
|
|
117
|
-
|
|
118
|
-
return false; // Skip
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// --- FIX: Run ALL calc types (Standard, Meta, Price) ---
|
|
122
|
-
const calcsToAttempt = calcsInThisPass.filter(shouldRun);
|
|
111
|
+
}
|
|
123
112
|
|
|
124
113
|
if (!calcsToAttempt.length) return null;
|
|
125
114
|
|
|
126
|
-
// 2. Check Root Data Availability
|
|
127
115
|
const earliestDates = {
|
|
128
116
|
portfolio: new Date('2025-09-25T00:00:00Z'),
|
|
129
117
|
history: new Date('2025-11-05T00:00:00Z'),
|
|
@@ -138,14 +126,11 @@ async function runDateComputation(dateStr, passToRun, calcsInThisPass, config, d
|
|
|
138
126
|
return null;
|
|
139
127
|
}
|
|
140
128
|
|
|
141
|
-
// 3. Filter again based on Root Data availability
|
|
142
129
|
const runnableCalcs = calcsToAttempt.filter(c => checkRootDependencies(c, rootData.status).canRun);
|
|
143
130
|
|
|
144
131
|
if (!runnableCalcs.length) return null;
|
|
145
132
|
|
|
146
|
-
// Split into Standard (Streaming) and Meta (Once-Per-Day/Price)
|
|
147
133
|
const standardToRun = runnableCalcs.filter(c => c.type === 'standard');
|
|
148
|
-
// Note: Meta includes Price calcs in this flow
|
|
149
134
|
const metaToRun = runnableCalcs.filter(c => c.type === 'meta');
|
|
150
135
|
|
|
151
136
|
logger.log('INFO', `[DateRunner] Running ${dateStr}: ${standardToRun.length} std, ${metaToRun.length} meta`);
|
|
@@ -155,7 +140,6 @@ async function runDateComputation(dateStr, passToRun, calcsInThisPass, config, d
|
|
|
155
140
|
try {
|
|
156
141
|
const calcsRunning = [...standardToRun, ...metaToRun];
|
|
157
142
|
|
|
158
|
-
// Fetch dependencies (results from this day or yesterday)
|
|
159
143
|
const existingResults = await fetchExistingResults(dateStr, calcsRunning, computationManifest, config, dependencies, false);
|
|
160
144
|
const prevDate = new Date(dateToProcess); prevDate.setUTCDate(prevDate.getUTCDate() - 1);
|
|
161
145
|
const prevDateStr = prevDate.toISOString().slice(0, 10);
|
|
@@ -166,14 +150,13 @@ async function runDateComputation(dateStr, passToRun, calcsInThisPass, config, d
|
|
|
166
150
|
Object.assign(dateUpdates, updates);
|
|
167
151
|
}
|
|
168
152
|
if (metaToRun.length) {
|
|
169
|
-
// runMetaComputationPass uses the Controller, which handles Price Sharding logic internally for single dates.
|
|
170
153
|
const updates = await runMetaComputationPass(dateToProcess, metaToRun, `Pass ${passToRun} (Meta)`, config, dependencies, existingResults, previousResults, rootData, false);
|
|
171
154
|
Object.assign(dateUpdates, updates);
|
|
172
155
|
}
|
|
173
156
|
} catch (err) {
|
|
174
157
|
logger.log('ERROR', `[DateRunner] FAILED Pass ${passToRun} for ${dateStr}`, { errorMessage: err.message });
|
|
175
158
|
[...standardToRun, ...metaToRun].forEach(c => dateUpdates[normalizeName(c.name)] = false);
|
|
176
|
-
throw err;
|
|
159
|
+
throw err;
|
|
177
160
|
}
|
|
178
161
|
|
|
179
162
|
if (Object.keys(dateUpdates).length > 0) {
|
|
@@ -1,45 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Extractors Layer
|
|
3
|
-
* Core access methods to raw data
|
|
3
|
+
* Core access methods to raw data.
|
|
4
|
+
* FIX: HistoryExtractor handles both Legacy and Modern (Granular) schemas.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
const { SCHEMAS } = require('./profiling');
|
|
7
8
|
|
|
8
9
|
class TradeSeriesBuilder {
|
|
9
|
-
/**
|
|
10
|
-
* Converts raw trade history into a time-series of Strategy Returns.
|
|
11
|
-
* Assumes a "Flat Bet" model (equal sizing) because absolute position size is not available in history.
|
|
12
|
-
* This creates a normalized performance curve for the user's *decisions*.
|
|
13
|
-
* @param {Array} historyTrades - PublicHistoryPositions array.
|
|
14
|
-
* @returns {Array<number>} Array of NetProfit% values sorted by close date.
|
|
15
|
-
*/
|
|
16
10
|
static buildReturnSeries(historyTrades) {
|
|
17
11
|
if (!historyTrades || !Array.isArray(historyTrades)) return [];
|
|
18
|
-
|
|
19
|
-
// 1. Filter valid closed trades
|
|
20
12
|
const closedTrades = historyTrades.filter(t => t.CloseDateTime && typeof t.NetProfit === 'number');
|
|
21
|
-
|
|
22
|
-
// 2. Sort by Close Date (Ascending)
|
|
23
13
|
closedTrades.sort((a, b) => new Date(a.CloseDateTime) - new Date(b.CloseDateTime));
|
|
24
|
-
|
|
25
|
-
// 3. Extract the PnL sequence
|
|
26
14
|
return closedTrades.map(t => t.NetProfit);
|
|
27
15
|
}
|
|
28
16
|
|
|
29
|
-
/**
|
|
30
|
-
* Builds a cumulative equity curve (starting at 100) based on compounding trade returns.
|
|
31
|
-
* Useful for visualising the trajectory of the strategy.
|
|
32
|
-
*/
|
|
33
17
|
static buildCumulativeCurve(returnSeries, startValue = 100) {
|
|
34
18
|
const curve = [startValue];
|
|
35
19
|
let current = startValue;
|
|
36
|
-
|
|
37
20
|
for (const ret of returnSeries) {
|
|
38
|
-
// Apply return (e.g. 5% profit -> * 1.05)
|
|
39
|
-
// Note: NetProfit in eToro history is usually percentage (e.g. 5.4954).
|
|
40
|
-
// We treat this as the return on *that specific position*.
|
|
41
|
-
// In a flat-bet model, we assume that position was X% of the portfolio.
|
|
42
|
-
// Simplified: We just accumulate the "points" captured.
|
|
43
21
|
current = current * (1 + (ret / 100));
|
|
44
22
|
curve.push(current);
|
|
45
23
|
}
|
|
@@ -48,177 +26,84 @@ class TradeSeriesBuilder {
|
|
|
48
26
|
}
|
|
49
27
|
|
|
50
28
|
class DataExtractor {
|
|
51
|
-
// ========================================================================
|
|
52
|
-
// 1. COLLECTION ACCESSORS
|
|
53
|
-
// ========================================================================
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Extract positions array based on User Type.
|
|
57
|
-
* - Normal: Uses 'AggregatedPositions' (Grouped by Asset + Direction)
|
|
58
|
-
* - Speculator: Uses 'PublicPositions' (Individual Trades)
|
|
59
|
-
*/
|
|
60
29
|
static getPositions(portfolio, userType) {
|
|
61
|
-
if (!portfolio) return [];
|
|
62
|
-
|
|
30
|
+
if (!portfolio) return [];
|
|
63
31
|
if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
|
|
64
|
-
return portfolio.PublicPositions || [];
|
|
32
|
+
return portfolio.PublicPositions || [];
|
|
65
33
|
}
|
|
66
|
-
|
|
67
|
-
// Default to Normal User Schema
|
|
68
34
|
return portfolio.AggregatedPositions || [];
|
|
69
35
|
}
|
|
70
36
|
|
|
71
|
-
// ========================================================================
|
|
72
|
-
// 2. IDENTITY & KEYS
|
|
73
|
-
// ========================================================================
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Extract standardized Instrument ID.
|
|
77
|
-
*/
|
|
78
37
|
static getInstrumentId(position) {
|
|
79
|
-
if (!position) return null;
|
|
80
|
-
// Handle string or number variations safely
|
|
38
|
+
if (!position) return null;
|
|
81
39
|
return position.InstrumentID || position.instrumentId || null;
|
|
82
40
|
}
|
|
83
41
|
|
|
84
|
-
/**
|
|
85
|
-
* Extract a unique Identifier for the position.
|
|
86
|
-
* - Speculator: Uses 'PositionID'.
|
|
87
|
-
* - Normal: Generates Composite Key (InstrumentID_Direction) since they lack unique Trade IDs.
|
|
88
|
-
*/
|
|
89
42
|
static getPositionId(position) {
|
|
90
|
-
if (!position) return null;
|
|
91
|
-
|
|
92
|
-
// 1. Try Explicit ID (Speculators)
|
|
43
|
+
if (!position) return null;
|
|
93
44
|
if (position.PositionID) return String(position.PositionID);
|
|
94
45
|
if (position.PositionId) return String(position.PositionId);
|
|
95
|
-
|
|
96
|
-
// 2. Fallback to Composite Key (Normal Users)
|
|
97
46
|
const instId = this.getInstrumentId(position);
|
|
98
47
|
const dir = this.getDirection(position);
|
|
99
48
|
if (instId) return `${instId}_${dir}`;
|
|
100
|
-
|
|
101
49
|
return null;
|
|
102
50
|
}
|
|
103
51
|
|
|
104
|
-
// ========================================================================
|
|
105
|
-
// 3. FINANCIAL METRICS (WEIGHTS & P&L)
|
|
106
|
-
// ========================================================================
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Extract Net Profit %.
|
|
110
|
-
* Schema: 'NetProfit' is the percentage profit relative to invested capital.
|
|
111
|
-
*/
|
|
112
52
|
static getNetProfit(position) {
|
|
113
53
|
return position ? (position.NetProfit || 0) : 0;
|
|
114
54
|
}
|
|
115
55
|
|
|
116
|
-
|
|
117
|
-
* Extract Position Weight (Allocation %).
|
|
118
|
-
* Schema:
|
|
119
|
-
* - Normal: 'Invested' is % of initial capital.
|
|
120
|
-
* - Speculator: 'Invested' (or 'Amount' in some contexts) is % of initial capital.
|
|
121
|
-
*/
|
|
122
|
-
static getPositionWeight(position, userType) { // Agnostic on user type, unused.
|
|
56
|
+
static getPositionWeight(position, userType) {
|
|
123
57
|
if (!position) return 0;
|
|
124
|
-
|
|
125
|
-
// Both schemas use 'Invested' to represent the allocation percentage.
|
|
126
|
-
// Speculators might optionally have 'Amount', we prioritize 'Invested' for consistency.
|
|
127
58
|
return position.Invested || position.Amount || 0;
|
|
128
59
|
}
|
|
129
60
|
|
|
130
|
-
/**
|
|
131
|
-
* Extract Current Equity Value %.
|
|
132
|
-
* Schema: 'Value' is the current value as a % of total portfolio equity.
|
|
133
|
-
*/
|
|
134
61
|
static getPositionValuePct(position) {
|
|
135
62
|
return position ? (position.Value || 0) : 0;
|
|
136
63
|
}
|
|
137
64
|
|
|
138
|
-
/**
|
|
139
|
-
* --- NEW PRIMITIVE ---
|
|
140
|
-
* Derives the approximate Entry Price of a position based on Current Price and Net Profit %.
|
|
141
|
-
* Formula: Entry = Current / (1 + (NetProfit / 100))
|
|
142
|
-
* @param {number} currentPrice - The current market price of the asset.
|
|
143
|
-
* @param {number} netProfitPct - The Net Profit percentage (e.g., -20.5).
|
|
144
|
-
* @returns {number} Estimated Entry Price.
|
|
145
|
-
*/
|
|
146
65
|
static deriveEntryPrice(currentPrice, netProfitPct) {
|
|
147
66
|
if (!currentPrice || currentPrice <= 0) return 0;
|
|
148
|
-
|
|
149
|
-
if (netProfitPct <= -100) return Number.MAX_SAFE_INTEGER; // Effectively infinite entry price (lost everything)
|
|
67
|
+
if (netProfitPct <= -100) return Number.MAX_SAFE_INTEGER;
|
|
150
68
|
return currentPrice / (1 + (netProfitPct / 100.0));
|
|
151
69
|
}
|
|
152
70
|
|
|
153
|
-
// ========================================================================
|
|
154
|
-
// 4. PORTFOLIO LEVEL SUMMARY
|
|
155
|
-
// ========================================================================
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Calculate/Extract Daily Portfolio P&L %.
|
|
159
|
-
*/
|
|
160
71
|
static getPortfolioDailyPnl(portfolio, userType) {
|
|
161
72
|
if (!portfolio) return 0;
|
|
162
|
-
|
|
163
|
-
// 1. Speculator (Explicit 'NetProfit' field on root)
|
|
164
73
|
if (userType === SCHEMAS.USER_TYPES.SPECULATOR) {
|
|
165
74
|
return portfolio.NetProfit || 0;
|
|
166
75
|
}
|
|
167
|
-
|
|
168
|
-
// 2. Normal (Aggregated Calculation)
|
|
169
76
|
if (portfolio.AggregatedPositionsByInstrumentTypeID) {
|
|
170
77
|
return portfolio.AggregatedPositionsByInstrumentTypeID.reduce((sum, agg) => {
|
|
171
78
|
return sum + ((agg.Value || 0) - (agg.Invested || 0));
|
|
172
79
|
}, 0);
|
|
173
80
|
}
|
|
174
|
-
|
|
175
81
|
return 0;
|
|
176
82
|
}
|
|
177
83
|
|
|
178
|
-
// ========================================================================
|
|
179
|
-
// 5. TRADE DETAILS (SPECULATOR SPECIFIC)
|
|
180
|
-
// ========================================================================
|
|
181
|
-
|
|
182
84
|
static getDirection(position) {
|
|
183
85
|
if (!position) return "Buy";
|
|
184
86
|
if (position.Direction) return position.Direction;
|
|
185
87
|
if (typeof position.IsBuy === 'boolean') return position.IsBuy ? "Buy" : "Sell";
|
|
186
|
-
return "Buy";
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
static getLeverage(position) {
|
|
190
|
-
return position ? (position.Leverage || 1) : 1; // Default 1 IF NOT FOUND
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
static getOpenRate(position) {
|
|
194
|
-
return position ? (position.OpenRate || 0) : 0; // Default 0 IF NOT FOUND
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
static getCurrentRate(position) {
|
|
198
|
-
return position ? (position.CurrentRate || 0) : 0; // Default 0 IF NOT FOUND
|
|
88
|
+
return "Buy";
|
|
199
89
|
}
|
|
200
90
|
|
|
91
|
+
static getLeverage(position) { return position ? (position.Leverage || 1) : 1; }
|
|
92
|
+
static getOpenRate(position) { return position ? (position.OpenRate || 0) : 0; }
|
|
93
|
+
static getCurrentRate(position) { return position ? (position.CurrentRate || 0) : 0; }
|
|
201
94
|
static getStopLossRate(position) {
|
|
202
95
|
const rate = position ? (position.StopLossRate || 0) : 0;
|
|
203
|
-
if (rate > 0 && rate <= 0.01) return 0;
|
|
96
|
+
if (rate > 0 && rate <= 0.01) return 0;
|
|
204
97
|
if (rate < 0) return 0;
|
|
205
98
|
return rate;
|
|
206
99
|
}
|
|
207
|
-
|
|
208
100
|
static getTakeProfitRate(position) {
|
|
209
101
|
const rate = position ? (position.TakeProfitRate || 0) : 0;
|
|
210
|
-
if (rate > 0 && rate <= 0.01) return 0;
|
|
102
|
+
if (rate > 0 && rate <= 0.01) return 0;
|
|
211
103
|
return rate;
|
|
212
104
|
}
|
|
213
|
-
|
|
214
|
-
static
|
|
215
|
-
return position ? (position.HasTrailingStopLoss === true) : false; // Default false IF NOT FOUND
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
static getOpenDateTime(position) {
|
|
219
|
-
if (!position || !position.OpenDateTime) return null;
|
|
220
|
-
return new Date(position.OpenDateTime);
|
|
221
|
-
}
|
|
105
|
+
static getHasTSL(position) { return position ? (position.HasTrailingStopLoss === true) : false; }
|
|
106
|
+
static getOpenDateTime(position) { return (!position || !position.OpenDateTime) ? null : new Date(position.OpenDateTime); }
|
|
222
107
|
}
|
|
223
108
|
|
|
224
109
|
class priceExtractor {
|
|
@@ -235,7 +120,6 @@ class priceExtractor {
|
|
|
235
120
|
}
|
|
236
121
|
|
|
237
122
|
if (!assetData || !assetData.prices) return [];
|
|
238
|
-
|
|
239
123
|
const priceMap = assetData.prices;
|
|
240
124
|
const sortedDates = Object.keys(priceMap).sort((a, b) => a.localeCompare(b));
|
|
241
125
|
|
|
@@ -247,14 +131,11 @@ class priceExtractor {
|
|
|
247
131
|
|
|
248
132
|
static getAllHistories(pricesContext) {
|
|
249
133
|
if (!pricesContext || !pricesContext.history) return new Map();
|
|
250
|
-
|
|
251
134
|
const results = new Map();
|
|
252
135
|
for (const [id, data] of Object.entries(pricesContext.history)) {
|
|
253
136
|
const ticker = data.ticker || id;
|
|
254
137
|
const history = this.getHistory(pricesContext, id);
|
|
255
|
-
if (history.length > 0)
|
|
256
|
-
results.set(ticker, history);
|
|
257
|
-
}
|
|
138
|
+
if (history.length > 0) results.set(ticker, history);
|
|
258
139
|
}
|
|
259
140
|
return results;
|
|
260
141
|
}
|
|
@@ -266,38 +147,45 @@ class HistoryExtractor {
|
|
|
266
147
|
}
|
|
267
148
|
|
|
268
149
|
static getTradedAssets(historyDoc) {
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
for (const t of trades) {
|
|
275
|
-
const instId = t.InstrumentID;
|
|
276
|
-
if (!instId) continue;
|
|
277
|
-
|
|
278
|
-
if (!assetsMap.has(instId)) {
|
|
279
|
-
assetsMap.set(instId, {
|
|
280
|
-
instrumentId: instId,
|
|
281
|
-
totalDuration: 0,
|
|
282
|
-
count: 0
|
|
283
|
-
});
|
|
284
|
-
}
|
|
150
|
+
// 1. Try Modern Granular Data (Derive Assets from Trades)
|
|
151
|
+
if (historyDoc?.PublicHistoryPositions?.length) {
|
|
152
|
+
const trades = historyDoc.PublicHistoryPositions;
|
|
153
|
+
const assetsMap = new Map();
|
|
285
154
|
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
155
|
+
for (const t of trades) {
|
|
156
|
+
const instId = t.InstrumentID;
|
|
157
|
+
if (!instId) continue;
|
|
158
|
+
|
|
159
|
+
if (!assetsMap.has(instId)) {
|
|
160
|
+
assetsMap.set(instId, {
|
|
161
|
+
instrumentId: instId,
|
|
162
|
+
totalDuration: 0,
|
|
163
|
+
count: 0
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const asset = assetsMap.get(instId);
|
|
168
|
+
const open = new Date(t.OpenDateTime);
|
|
169
|
+
const close = new Date(t.CloseDateTime);
|
|
170
|
+
const durationMins = (close - open) / 60000;
|
|
171
|
+
|
|
172
|
+
if (durationMins > 0) {
|
|
173
|
+
asset.totalDuration += durationMins;
|
|
174
|
+
asset.count++;
|
|
175
|
+
}
|
|
294
176
|
}
|
|
177
|
+
return Array.from(assetsMap.values()).map(a => ({
|
|
178
|
+
instrumentId: a.instrumentId,
|
|
179
|
+
avgHoldingTimeInMinutes: a.count > 0 ? (a.totalDuration / a.count) : 0
|
|
180
|
+
}));
|
|
295
181
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
182
|
+
|
|
183
|
+
// 2. Fallback to Legacy 'assets' array
|
|
184
|
+
if (historyDoc?.assets && Array.isArray(historyDoc.assets)) {
|
|
185
|
+
return historyDoc.assets;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [];
|
|
301
189
|
}
|
|
302
190
|
|
|
303
191
|
static getInstrumentId(asset) {
|
|
@@ -309,79 +197,63 @@ class HistoryExtractor {
|
|
|
309
197
|
}
|
|
310
198
|
|
|
311
199
|
static getSummary(historyDoc) {
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
200
|
+
// 1. Try Modern Granular Data (Derive Summary)
|
|
201
|
+
if (historyDoc?.PublicHistoryPositions?.length) {
|
|
202
|
+
const trades = historyDoc.PublicHistoryPositions;
|
|
203
|
+
let totalTrades = trades.length;
|
|
204
|
+
let wins = 0;
|
|
205
|
+
let totalProf = 0;
|
|
206
|
+
let totalLoss = 0;
|
|
207
|
+
let profCount = 0;
|
|
208
|
+
let lossCount = 0;
|
|
209
|
+
let totalDur = 0;
|
|
210
|
+
|
|
211
|
+
for (const t of trades) {
|
|
212
|
+
if (t.NetProfit > 0) {
|
|
213
|
+
wins++;
|
|
214
|
+
totalProf += t.NetProfit;
|
|
215
|
+
profCount++;
|
|
216
|
+
} else if (t.NetProfit < 0) {
|
|
217
|
+
totalLoss += t.NetProfit;
|
|
218
|
+
lossCount++;
|
|
219
|
+
}
|
|
220
|
+
const open = new Date(t.OpenDateTime);
|
|
221
|
+
const close = new Date(t.CloseDateTime);
|
|
222
|
+
totalDur += (close - open) / 60000;
|
|
331
223
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
totalTrades: totalTrades,
|
|
227
|
+
winRatio: totalTrades > 0 ? (wins / totalTrades) * 100 : 0,
|
|
228
|
+
avgProfitPct: profCount > 0 ? totalProf / profCount : 0,
|
|
229
|
+
avgLossPct: lossCount > 0 ? totalLoss / lossCount : 0,
|
|
230
|
+
avgHoldingTimeInMinutes: totalTrades > 0 ? totalDur / totalTrades : 0
|
|
231
|
+
};
|
|
336
232
|
}
|
|
337
233
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
};
|
|
234
|
+
// 2. Fallback to Legacy 'all' object
|
|
235
|
+
if (historyDoc?.all) {
|
|
236
|
+
return historyDoc.all;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return null;
|
|
345
240
|
}
|
|
346
241
|
}
|
|
347
242
|
|
|
348
243
|
class InsightsExtractor {
|
|
349
|
-
/**
|
|
350
|
-
* Extracts the raw array of insight objects from the context.
|
|
351
|
-
* Checks for standard context injection paths.
|
|
352
|
-
*/
|
|
353
244
|
static getInsights(context) {
|
|
354
|
-
// Support multiple potential injection paths depending on controller version
|
|
355
245
|
return context.insights || context.daily_instrument_insights || [];
|
|
356
246
|
}
|
|
357
247
|
|
|
358
|
-
/**
|
|
359
|
-
* returns the specific insight object for a given instrument ID.
|
|
360
|
-
*/
|
|
361
248
|
static getInsightForInstrument(insights, instrumentId) {
|
|
362
249
|
if (!insights || !Array.isArray(insights)) return null;
|
|
363
250
|
return insights.find(i => i.instrumentId === instrumentId) || null;
|
|
364
251
|
}
|
|
365
252
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
static
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
static getLongPercent(insight) {
|
|
373
|
-
return insight ? (insight.buy || 0) : 0;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
static getShortPercent(insight) {
|
|
377
|
-
return insight ? (insight.sell || 0) : 0;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
static getGrowthPercent(insight) {
|
|
381
|
-
return insight ? (insight.growth || 0) : 0;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// --- Derived Counts (Estimated) ---
|
|
253
|
+
static getTotalOwners(insight) { return insight ? (insight.total || 0) : 0; }
|
|
254
|
+
static getLongPercent(insight) { return insight ? (insight.buy || 0) : 0; }
|
|
255
|
+
static getShortPercent(insight) { return insight ? (insight.sell || 0) : 0; }
|
|
256
|
+
static getGrowthPercent(insight) { return insight ? (insight.growth || 0) : 0; }
|
|
385
257
|
|
|
386
258
|
static getLongCount(insight) {
|
|
387
259
|
const total = this.getTotalOwners(insight);
|
|
@@ -395,19 +267,11 @@ class InsightsExtractor {
|
|
|
395
267
|
return Math.floor(total * (sellPct / 100));
|
|
396
268
|
}
|
|
397
269
|
|
|
398
|
-
/**
|
|
399
|
-
* Calculates the net change in users from yesterday based on growth %.
|
|
400
|
-
* Formula: NetChange = Total - (Total / (1 + Growth/100))
|
|
401
|
-
*/
|
|
402
270
|
static getNetOwnershipChange(insight) {
|
|
403
271
|
const total = this.getTotalOwners(insight);
|
|
404
272
|
const growth = this.getGrowthPercent(insight);
|
|
405
273
|
if (total === 0) return 0;
|
|
406
|
-
|
|
407
|
-
// Reverse engineer yesterday's count
|
|
408
|
-
// Today = Yesterday * (1 + growth)
|
|
409
|
-
// Yesterday = Today / (1 + growth)
|
|
410
|
-
const prevTotal = total / (1 + (growth / 100)); // TODO: Check precision issues
|
|
274
|
+
const prevTotal = total / (1 + (growth / 100));
|
|
411
275
|
return Math.round(total - prevTotal);
|
|
412
276
|
}
|
|
413
277
|
}
|
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* FILENAME: CloudFunctions/NpmWrappers/bulltrackers-module/functions/task-engine/helpers/update_helpers.js
|
|
3
|
-
* (OPTIMIZED
|
|
4
|
-
*
|
|
5
|
-
* (OPTIMIZED V2: Added "Circuit Breaker" for Proxy failures)
|
|
6
|
-
* (REFACTORED: Concurrency set to 1, added fallback and verbose logging)
|
|
7
|
-
* (FIXED: Improved logging clarity for Normal vs Speculator users)
|
|
8
|
-
* (FIXED: Final log now accurately reflects failure state)
|
|
3
|
+
* (OPTIMIZED V5: Filter Copy Trash from History)
|
|
4
|
+
* FIX: Filters PublicHistoryPositions to keep only valid close reasons (0, 1, 5).
|
|
9
5
|
*/
|
|
10
6
|
|
|
11
7
|
const { FieldValue } = require('@google-cloud/firestore');
|
|
12
8
|
const crypto = require('crypto');
|
|
13
9
|
|
|
14
10
|
// --- CIRCUIT BREAKER STATE ---
|
|
15
|
-
// Persists across function invocations in the same instance.
|
|
16
|
-
// If the Proxy fails 3 times in a row, we stop trying it to save the 5s timeout cost.
|
|
17
11
|
let _consecutiveProxyFailures = 0;
|
|
18
12
|
const MAX_PROXY_FAILURES = 3;
|
|
19
13
|
|
|
20
|
-
/**
|
|
21
|
-
* Helper to check if we should attempt the proxy
|
|
22
|
-
*/
|
|
23
14
|
function shouldTryProxy() {
|
|
24
15
|
return _consecutiveProxyFailures < MAX_PROXY_FAILURES;
|
|
25
16
|
}
|
|
26
17
|
|
|
27
|
-
/**
|
|
28
|
-
* Helper to record proxy result
|
|
29
|
-
*/
|
|
30
18
|
function recordProxyOutcome(success) {
|
|
31
19
|
if (success) {
|
|
32
20
|
_consecutiveProxyFailures = 0;
|
|
@@ -35,14 +23,9 @@ function recordProxyOutcome(success) {
|
|
|
35
23
|
}
|
|
36
24
|
}
|
|
37
25
|
|
|
38
|
-
/**
|
|
39
|
-
* --- NEW HELPER: Speculator Detector ---
|
|
40
|
-
* intersections: (History: Leverage > 1) AND (Portfolio: Currently Owned)
|
|
41
|
-
*/
|
|
42
26
|
function detectSpeculatorTargets(historyData, portfolioData) {
|
|
43
27
|
if (!historyData?.PublicHistoryPositions || !portfolioData?.AggregatedPositions) return [];
|
|
44
28
|
|
|
45
|
-
// 1. Identify assets that have EVER been traded with leverage > 1
|
|
46
29
|
const leveragedAssets = new Set();
|
|
47
30
|
for (const pos of historyData.PublicHistoryPositions) {
|
|
48
31
|
if (pos.Leverage > 1 && pos.InstrumentID) {
|
|
@@ -52,7 +35,6 @@ function detectSpeculatorTargets(historyData, portfolioData) {
|
|
|
52
35
|
|
|
53
36
|
if (leveragedAssets.size === 0) return [];
|
|
54
37
|
|
|
55
|
-
// 2. Check if the user CURRENTLY owns any of these assets
|
|
56
38
|
const targets = [];
|
|
57
39
|
for (const pos of portfolioData.AggregatedPositions) {
|
|
58
40
|
if (leveragedAssets.has(pos.InstrumentID)) {
|
|
@@ -63,26 +45,18 @@ function detectSpeculatorTargets(historyData, portfolioData) {
|
|
|
63
45
|
return targets;
|
|
64
46
|
}
|
|
65
47
|
|
|
66
|
-
/**
|
|
67
|
-
* (REFACTORED: Fully sequential, verbose logging, node-fetch fallback)
|
|
68
|
-
*/
|
|
69
48
|
async function handleUpdate(task, taskId, { logger, headerManager, proxyManager, db, batchManager, pubsub }, config) {
|
|
70
49
|
const { userId, instruments, instrumentId, userType } = task;
|
|
71
50
|
|
|
72
|
-
// Normalize the loop: Speculators get specific IDs, Normal users get [undefined] to trigger one pass.
|
|
73
51
|
const instrumentsToProcess = userType === 'speculator' ? (instruments || [instrumentId]) : [undefined];
|
|
74
52
|
const today = new Date().toISOString().slice(0, 10);
|
|
75
53
|
const portfolioBlockId = `${Math.floor(parseInt(userId) / 1000000)}M`;
|
|
76
54
|
let isPrivate = false;
|
|
77
55
|
|
|
78
|
-
// Captured data for detection logic
|
|
79
56
|
let capturedHistory = null;
|
|
80
57
|
let capturedPortfolio = null;
|
|
81
|
-
|
|
82
|
-
// Track overall success for the final log
|
|
83
58
|
let hasPortfolioErrors = false;
|
|
84
59
|
|
|
85
|
-
// FIX 1: Better Start Log
|
|
86
60
|
const scopeLog = userType === 'speculator' ? `Instruments: [${instrumentsToProcess.join(', ')}]` : 'Scope: Full Portfolio';
|
|
87
61
|
logger.log('TRACE', `[handleUpdate/${userId}] Starting update task. Type: ${userType}. ${scopeLog}`);
|
|
88
62
|
|
|
@@ -99,7 +73,6 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
99
73
|
logger.log('WARN', `[handleUpdate/${userId}] Could not select history header. Skipping history.`);
|
|
100
74
|
} else {
|
|
101
75
|
|
|
102
|
-
// --- REFACTOR: New Granular API Logic ---
|
|
103
76
|
const d = new Date();
|
|
104
77
|
d.setFullYear(d.getFullYear() - 1);
|
|
105
78
|
const oneYearAgoStr = d.toISOString();
|
|
@@ -140,7 +113,18 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
140
113
|
|
|
141
114
|
if (wasHistorySuccess) {
|
|
142
115
|
const data = await response.json();
|
|
143
|
-
|
|
116
|
+
|
|
117
|
+
// --- FILTER LOGIC FOR GRANULAR API ---
|
|
118
|
+
// 0 = Manual, 1 = Stop Loss, 5 = Take Profit.
|
|
119
|
+
const VALID_REASONS = [0, 1, 5];
|
|
120
|
+
if (data.PublicHistoryPositions && Array.isArray(data.PublicHistoryPositions)) {
|
|
121
|
+
const originalCount = data.PublicHistoryPositions.length;
|
|
122
|
+
data.PublicHistoryPositions = data.PublicHistoryPositions.filter(p => VALID_REASONS.includes(p.CloseReason));
|
|
123
|
+
const filteredCount = data.PublicHistoryPositions.length;
|
|
124
|
+
logger.log('INFO', `[handleUpdate/${userId}] History Filter: Reduced ${originalCount} -> ${filteredCount} positions.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
capturedHistory = data;
|
|
144
128
|
await batchManager.addToTradingHistoryBatch(userId, portfolioBlockId, today, data, userType);
|
|
145
129
|
}
|
|
146
130
|
}
|
|
@@ -157,7 +141,6 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
157
141
|
logger.log('TRACE', `[handleUpdate/${userId}] Starting ${instrumentsToProcess.length} sequential portfolio fetches.`);
|
|
158
142
|
|
|
159
143
|
for (const instId of instrumentsToProcess) {
|
|
160
|
-
// FIX 2: Define a clear scope name for logging
|
|
161
144
|
const scopeName = instId ? `Instrument ${instId}` : 'Full Portfolio';
|
|
162
145
|
|
|
163
146
|
if (isPrivate) {
|
|
@@ -174,7 +157,6 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
174
157
|
let wasPortfolioSuccess = false;
|
|
175
158
|
let proxyUsedForPortfolio = false;
|
|
176
159
|
|
|
177
|
-
// --- PROXY ATTEMPT ---
|
|
178
160
|
if (shouldTryProxy()) {
|
|
179
161
|
try {
|
|
180
162
|
logger.log('TRACE', `[handleUpdate/${userId}] Attempting fetch for ${scopeName} via AppScript proxy...`);
|
|
@@ -190,7 +172,6 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
190
172
|
}
|
|
191
173
|
}
|
|
192
174
|
|
|
193
|
-
// --- DIRECT FALLBACK ---
|
|
194
175
|
if (!wasPortfolioSuccess) {
|
|
195
176
|
try {
|
|
196
177
|
response = await fetch(portfolioUrl, options);
|
|
@@ -203,35 +184,29 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
203
184
|
}
|
|
204
185
|
}
|
|
205
186
|
|
|
206
|
-
// --- 4. Process Portfolio Result ---
|
|
207
187
|
if (wasPortfolioSuccess) {
|
|
208
188
|
const body = await response.text();
|
|
209
189
|
if (body.includes("user is PRIVATE")) { isPrivate = true; logger.log('WARN', `[handleUpdate/${userId}] User is PRIVATE. Marking for removal.`); break; }
|
|
210
190
|
|
|
211
191
|
try {
|
|
212
192
|
const portfolioJson = JSON.parse(body);
|
|
213
|
-
capturedPortfolio = portfolioJson;
|
|
193
|
+
capturedPortfolio = portfolioJson;
|
|
214
194
|
await batchManager.addToPortfolioBatch(userId, portfolioBlockId, today, portfolioJson, userType, instId);
|
|
215
195
|
logger.log('TRACE', `[handleUpdate/${userId}] Portfolio for ${scopeName} processed successfully.`);
|
|
216
196
|
|
|
217
197
|
} catch (parseError) {
|
|
218
198
|
wasPortfolioSuccess = false;
|
|
219
|
-
hasPortfolioErrors = true;
|
|
199
|
+
hasPortfolioErrors = true;
|
|
220
200
|
logger.log('ERROR', `[handleUpdate/${userId}] FAILED TO PARSE JSON RESPONSE for ${scopeName}.`, { url: portfolioUrl, parseErrorMessage: parseError.message });
|
|
221
201
|
}
|
|
222
202
|
} else {
|
|
223
|
-
hasPortfolioErrors = true;
|
|
203
|
+
hasPortfolioErrors = true;
|
|
224
204
|
logger.log('WARN', `[handleUpdate/${userId}] Portfolio fetch FAILED for ${scopeName}.`);
|
|
225
205
|
}
|
|
226
206
|
|
|
227
207
|
if (proxyUsedForPortfolio) { headerManager.updatePerformance(portfolioHeader.id, wasPortfolioSuccess); }
|
|
228
208
|
}
|
|
229
209
|
|
|
230
|
-
// --- 5. SPECULATOR DETECTION & QUEUEING (NEW) ---
|
|
231
|
-
// Only run detection if:
|
|
232
|
-
// 1. We are processing a Normal User (userType !== 'speculator')
|
|
233
|
-
// 2. We successfully fetched both history and portfolio
|
|
234
|
-
// 3. We have PubSub available to queue new tasks
|
|
235
210
|
if (userType !== 'speculator' && capturedHistory && capturedPortfolio && pubsub && config.PUBSUB_TOPIC_TASK_ENGINE) {
|
|
236
211
|
try {
|
|
237
212
|
const speculatorAssets = detectSpeculatorTargets(capturedHistory, capturedPortfolio);
|
|
@@ -245,7 +220,6 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
245
220
|
instrumentId: assetId
|
|
246
221
|
}));
|
|
247
222
|
|
|
248
|
-
// Publish to Task Engine (Tasks are wrapped in a 'tasks' array payload)
|
|
249
223
|
const dataBuffer = Buffer.from(JSON.stringify({ tasks: newTasks }));
|
|
250
224
|
await pubsub.topic(config.PUBSUB_TOPIC_TASK_ENGINE).publishMessage({ data: dataBuffer });
|
|
251
225
|
}
|
|
@@ -254,7 +228,6 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
254
228
|
}
|
|
255
229
|
}
|
|
256
230
|
|
|
257
|
-
// --- 6. Handle Private Users & Timestamps ---
|
|
258
231
|
if (isPrivate) {
|
|
259
232
|
logger.log('WARN', `[handleUpdate/${userId}] Removing private user from updates.`);
|
|
260
233
|
for (const instrumentId of instrumentsToProcess) {
|
|
@@ -268,17 +241,12 @@ async function handleUpdate(task, taskId, { logger, headerManager, proxyManager,
|
|
|
268
241
|
return;
|
|
269
242
|
}
|
|
270
243
|
|
|
271
|
-
// If not private AND no critical errors, update timestamps
|
|
272
|
-
// (We update timestamps even on partial failures for speculators to avoid infinite retry loops immediately,
|
|
273
|
-
// relying on the next scheduled run, but for Normal users, a failure usually means we should retry later.
|
|
274
|
-
// Current logic: Update timestamp to prevent immediate re-queueing.)
|
|
275
244
|
for (const instrumentId of instrumentsToProcess) {
|
|
276
245
|
await batchManager.updateUserTimestamp(userId, userType, instrumentId);
|
|
277
246
|
}
|
|
278
247
|
|
|
279
248
|
if (userType === 'speculator') { await batchManager.addSpeculatorTimestampFix(userId, String(Math.floor(userId/1e6)*1e6)); }
|
|
280
249
|
|
|
281
|
-
// FIX 3: Honest Final Log
|
|
282
250
|
if (hasPortfolioErrors) {
|
|
283
251
|
logger.log('WARN', `[handleUpdate/${userId}] Update task finished with ERRORS. See logs above.`);
|
|
284
252
|
} else {
|