bulltrackers-module 1.0.765 → 1.0.768

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.
Files changed (33) hide show
  1. package/functions/computation-system-v2/computations/BehavioralAnomaly.js +298 -186
  2. package/functions/computation-system-v2/computations/NewSectorExposure.js +82 -35
  3. package/functions/computation-system-v2/computations/NewSocialPost.js +52 -24
  4. package/functions/computation-system-v2/computations/PopularInvestorProfileMetrics.js +354 -641
  5. package/functions/computation-system-v2/config/bulltrackers.config.js +26 -14
  6. package/functions/computation-system-v2/framework/core/Manifest.js +9 -16
  7. package/functions/computation-system-v2/framework/core/RunAnalyzer.js +2 -1
  8. package/functions/computation-system-v2/framework/data/DataFetcher.js +142 -4
  9. package/functions/computation-system-v2/framework/execution/Orchestrator.js +119 -122
  10. package/functions/computation-system-v2/framework/storage/StorageManager.js +16 -18
  11. package/functions/computation-system-v2/framework/testing/ComputationTester.js +155 -66
  12. package/functions/computation-system-v2/handlers/scheduler.js +15 -5
  13. package/functions/computation-system-v2/scripts/test-computation-dag.js +109 -0
  14. package/functions/task-engine/helpers/data_storage_helpers.js +6 -6
  15. package/package.json +1 -1
  16. package/functions/computation-system-v2/computations/PopularInvestorRiskAssessment.js +0 -176
  17. package/functions/computation-system-v2/computations/PopularInvestorRiskMetrics.js +0 -294
  18. package/functions/computation-system-v2/computations/UserPortfolioSummary.js +0 -172
  19. package/functions/computation-system-v2/scripts/migrate-sectors.js +0 -73
  20. package/functions/computation-system-v2/test/analyze-results.js +0 -238
  21. package/functions/computation-system-v2/test/other/test-dependency-cascade.js +0 -150
  22. package/functions/computation-system-v2/test/other/test-dispatcher.js +0 -317
  23. package/functions/computation-system-v2/test/other/test-framework.js +0 -500
  24. package/functions/computation-system-v2/test/other/test-real-execution.js +0 -166
  25. package/functions/computation-system-v2/test/other/test-real-integration.js +0 -194
  26. package/functions/computation-system-v2/test/other/test-refactor-e2e.js +0 -131
  27. package/functions/computation-system-v2/test/other/test-results.json +0 -31
  28. package/functions/computation-system-v2/test/other/test-risk-metrics-computation.js +0 -329
  29. package/functions/computation-system-v2/test/other/test-scheduler.js +0 -204
  30. package/functions/computation-system-v2/test/other/test-storage.js +0 -449
  31. package/functions/computation-system-v2/test/run-pipeline-test.js +0 -554
  32. package/functions/computation-system-v2/test/test-full-pipeline.js +0 -227
  33. package/functions/computation-system-v2/test/test-worker-pool.js +0 -266
@@ -1,86 +1,175 @@
1
1
  /**
2
- * @fileoverview Unit Testing Utility for Computations
2
+ * @fileoverview Integration Tester
3
+ * Wraps the Orchestrator to run computations in a test environment.
4
+ * - Redirects output to a test table.
5
+ * - Resolves and executes dependency chains.
6
+ * - Finds valid runnable dates based on raw data availability.
7
+ * * * UPDATE: Removed SQL output fetching support.
3
8
  */
4
9
 
5
- class ComputationTester {
6
- constructor(ComputationClass, config) {
7
- this.ComputationClass = ComputationClass;
8
- this.config = config;
9
- this.mockData = {};
10
- this.mockDependencies = {};
11
- }
12
-
13
- withMockData(tableName, data) {
14
- this.mockData[tableName] = data;
15
- return this;
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { Orchestrator } = require('../execution/Orchestrator');
13
+ const { ManifestBuilder } = require('../core/Manifest');
14
+
15
+ class IntegrationTester {
16
+ constructor(baseConfig, logger = console) {
17
+ this.baseConfig = baseConfig;
18
+ this.logger = logger;
19
+ this.testConfig = this._createTestConfig(baseConfig);
16
20
  }
17
-
18
- withMockDependency(computationName, results) {
19
- this.mockDependencies[computationName] = results;
20
- return this;
21
+
22
+ /**
23
+ * Create a modified config for testing
24
+ * - Writes to computation_results_test
25
+ * - Disables Cloud Tasks
26
+ * - Forces local execution (no worker pool)
27
+ */
28
+ _createTestConfig(config) {
29
+ const testConfig = JSON.parse(JSON.stringify(config)); // Deep clone
30
+
31
+ // 1. Redirect Storage
32
+ if (!testConfig.resultStore) testConfig.resultStore = {};
33
+ testConfig.resultStore.table = 'computation_results_test';
34
+
35
+ // 2. Disable Side Effects
36
+ testConfig.cloudTasks = null; // Don't trigger downstream
37
+
38
+ // 3. Force Local Execution for simplified debugging
39
+ if (testConfig.workerPool) {
40
+ testConfig.workerPool.enabled = false;
41
+ }
42
+
43
+ // Preserve the original computation classes (JSON.stringify lost them)
44
+ testConfig.computations = config.computations;
45
+ // Preserve rules (functions)
46
+ testConfig.rules = config.rules;
47
+
48
+ return testConfig;
21
49
  }
22
-
23
- async run(entityId = 'test_entity') {
24
- const instance = new this.ComputationClass();
50
+
51
+ /**
52
+ * Build a standalone Orchestrator instance restricted to the specific dependency chain
53
+ */
54
+ async setupForComputation(targetName) {
55
+ // 1. Identify Dependency Chain
56
+ const allComputations = this.testConfig.computations;
57
+ const chain = this._resolveDependencyChain(targetName, allComputations);
25
58
 
26
- const context = {
27
- date: '2026-01-24',
28
- entityId,
29
- data: this.mockData,
30
- dependencies: this.mockDependencies,
31
- rules: this.config.rules,
32
- config: this.config,
33
- logger: { log: () => {} }
34
- };
59
+ this.logger.log('INFO', `[Tester] execution chain: ${chain.map(c => c.getConfig().name).join(' -> ')}`);
60
+
61
+ // 2. Configure Orchestrator with ONLY this chain
62
+ const runConfig = { ...this.testConfig, computations: chain };
63
+ this.orchestrator = new Orchestrator(runConfig, this.logger);
64
+ await this.orchestrator.initialize();
35
65
 
36
- instance._meta = { logger: context.logger, config: this.config };
66
+ return chain;
67
+ }
68
+
69
+ /**
70
+ * Find the most recent date where Raw Data requirements are met
71
+ */
72
+ async findLatestRunnableDate(targetName, lookbackDays = 7) {
73
+ if (!this.orchestrator) await this.setupForComputation(targetName);
74
+
75
+ const manifest = this.orchestrator.manifest;
76
+ const chainNames = new Set(manifest.map(c => c.name));
37
77
 
38
- await instance.process(context);
78
+ // Collect all Raw Data requirements (exclude requirements that are other computations in the chain)
79
+ const rawRequirements = {};
39
80
 
40
- const allResults = await instance.getResult();
41
- return allResults[entityId] || allResults;
81
+ for (const entry of manifest) {
82
+ for (const [reqName, reqSpec] of Object.entries(entry.requires)) {
83
+ // If the requirement is NOT a computation in our chain, it's raw data
84
+ rawRequirements[reqName] = reqSpec;
85
+ }
86
+ }
87
+
88
+ this.logger.log('INFO', `[Tester] Checking data availability for: ${Object.keys(rawRequirements).join(', ')}`);
89
+
90
+ // Iterate backwards from today
91
+ const today = new Date();
92
+ for (let i = 1; i <= lookbackDays; i++) {
93
+ const d = new Date(today);
94
+ d.setDate(today.getDate() - i);
95
+ const dateStr = d.toISOString().slice(0, 10);
96
+
97
+ const availability = await this.orchestrator.dataFetcher.checkAvailability(rawRequirements, dateStr);
98
+
99
+ if (availability.canRun) {
100
+ this.logger.log('INFO', `[Tester] Found runnable date: ${dateStr}`);
101
+ return dateStr;
102
+ } else {
103
+ this.logger.log('DEBUG', `[Tester] Date ${dateStr} missing: ${availability.missing.join(', ')}`);
104
+ }
105
+ }
106
+
107
+ throw new Error(`No runnable date found in last ${lookbackDays} days.`);
42
108
  }
43
-
44
- async expect(entityId = 'test_entity') {
45
- const result = await this.run(entityId);
109
+
110
+ async run(targetName, dateStr) {
111
+ if (!this.orchestrator) await this.setupForComputation(targetName);
112
+
113
+ // Ensure test table exists
114
+ await this.orchestrator.storageManager._ensureBigQueryTable('computation_results_test');
46
115
 
116
+ this.logger.log('INFO', `[Tester] Running chain for ${targetName} on ${dateStr}`);
117
+
118
+ // Run the DAG
119
+ // Orchestrator will handle passes automatically (Deps first, then Target)
120
+ const summary = await this.orchestrator.execute({
121
+ date: dateStr,
122
+ force: true // Force run even if "up to date" in test table
123
+ });
124
+
125
+ // UPDATE: Removed SQL output fetching logic.
126
+ // Always fetch from the standard State Repository (JSON result store).
127
+ const result = await this.orchestrator.stateRepository.getResult(dateStr, targetName);
128
+
47
129
  return {
48
- result,
49
-
50
- toHaveProperty(prop, value) {
51
- const actual = this._getNestedProperty(result, prop);
52
- if (value !== undefined && actual !== value) {
53
- throw new Error(`Expected ${prop} to be ${value}, got ${actual}`);
54
- }
55
- if (actual === undefined) {
56
- throw new Error(`Expected ${prop} to exist`);
57
- }
58
- return this;
59
- },
130
+ date: dateStr,
131
+ summary,
132
+ result
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Recursively find all dependencies for the target computation
138
+ */
139
+ _resolveDependencyChain(targetName, allComputations) {
140
+ const compMap = new Map(allComputations.map(c => {
141
+ try {
142
+ const conf = c.getConfig();
143
+ return [conf.name.toLowerCase(), c];
144
+ } catch (e) { return [null, null]; }
145
+ }));
146
+
147
+ const chain = new Set();
148
+ const visit = (name) => {
149
+ const key = name.toLowerCase();
150
+ if (chain.has(key)) return;
60
151
 
61
- toMatchSchema(schema) {
62
- const errors = this._validateSchema(result, schema);
63
- if (errors.length > 0) {
64
- throw new Error(`Schema validation failed: ${errors.join(', ')}`);
65
- }
66
- return this;
67
- },
152
+ const compClass = compMap.get(key);
153
+ if (!compClass) throw new Error(`Computation '${name}' not found.`);
68
154
 
69
- _getNestedProperty(obj, path) {
70
- return path.split('.').reduce((curr, key) => curr?.[key], obj);
71
- },
155
+ const config = compClass.getConfig();
72
156
 
73
- _validateSchema(data, schema) {
74
- const errors = [];
75
- for (const [key, type] of Object.entries(schema)) {
76
- if (typeof data[key] !== type) {
77
- errors.push(`${key}: expected ${type}, got ${typeof data[key]}`);
78
- }
79
- }
80
- return errors;
157
+ // Recurse dependencies
158
+ if (config.dependencies) {
159
+ config.dependencies.forEach(dep => visit(dep));
81
160
  }
161
+ // Recurse conditional dependencies
162
+ if (config.conditionalDependencies) {
163
+ config.conditionalDependencies.forEach(cd => visit(cd.computation));
164
+ }
165
+
166
+ chain.add(key);
82
167
  };
168
+
169
+ visit(targetName);
170
+
171
+ return Array.from(chain).map(k => compMap.get(k));
83
172
  }
84
173
  }
85
174
 
86
- module.exports = { ComputationTester };
175
+ module.exports = { IntegrationTester };
@@ -121,17 +121,27 @@ async function runWatchdog(req, res) {
121
121
  // 1. Find Zombies
122
122
  const zombies = await storageManager.findZombies(ZOMBIE_THRESHOLD_MINUTES);
123
123
 
124
- if (zombies.length === 0) {
125
- return res.status(200).send('No zombies detected.');
124
+ // Filter out excessive attempts
125
+ const actionableZombies = [];
126
+ for (const z of zombies) {
127
+ if ((z.attempts || 0) >= 3) {
128
+ console.warn(`[Watchdog] Ignoring zombie ${z.name} (Checkpoint: ${z.checkpointId}) - Max attempts reached (${z.attempts})`);
129
+ continue;
130
+ }
131
+ actionableZombies.push(z);
132
+ }
133
+
134
+ if (actionableZombies.length === 0) {
135
+ return res.status(200).send('No recoverable zombies.');
126
136
  }
127
137
 
128
- console.log(`[Watchdog] 🧟 Found ${zombies.length} zombies. Initiating recovery...`);
138
+ console.log(`[Watchdog] 🧟 Found ${actionableZombies.length} zombies. Initiating recovery...`);
129
139
 
130
140
  // 2. Claim & Recover
131
141
  // We claim them first so the next watchdog doesn't grab them while we are dispatching
132
- await Promise.all(zombies.map(z => storageManager.claimZombie(z.checkpointId)));
142
+ await Promise.all(actionableZombies.map(z => storageManager.claimZombie(z.checkpointId)));
133
143
 
134
- const recoveryTasks = zombies.map(z => {
144
+ const recoveryTasks = actionableZombies.map(z => {
135
145
  const entry = manifest.find(m => m.name === z.name);
136
146
  if (!entry) {
137
147
  console.error(`[Watchdog] Computation ${z.name} no longer exists in manifest. Cannot recover.`);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @fileoverview Test Computation DAG CLI
3
+ * Usage: node scripts/test-computation-dag.js <ComputationName> [Date]
4
+ * Example: node scripts/test-computation-dag.js UserPortfolioSummary
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { IntegrationTester } = require('../framework/testing/ComputationTester');
10
+ const config = require('../config/bulltrackers.config');
11
+
12
+ // Setup logging
13
+ const logger = {
14
+ log: (level, msg) => console.log(`[${new Date().toISOString()}] ${level}: ${msg}`),
15
+ error: (msg) => console.error(`[ERROR] ${msg}`)
16
+ };
17
+
18
+ async function main() {
19
+ const args = process.argv.slice(2);
20
+ if (args.length < 1) {
21
+ console.log('Usage: node scripts/test-computation-dag.js <ComputationName> [YYYY-MM-DD]');
22
+ process.exit(1);
23
+ }
24
+
25
+ const targetName = args[0];
26
+ let dateStr = args[1]; // Optional
27
+
28
+ try {
29
+ console.log('┌────────────────────────────────────────────────────────┐');
30
+ console.log('│ COMPUTATION INTEGRATION TEST │');
31
+ console.log('└────────────────────────────────────────────────────────┘');
32
+ console.log(`Target: ${targetName}`);
33
+ console.log(`Mode: DAG Integration (Production Read / Test Write)`);
34
+
35
+ const tester = new IntegrationTester(config, logger);
36
+
37
+ // 1. Setup & Resolve Chain
38
+ await tester.setupForComputation(targetName);
39
+
40
+ // 2. Find Date if not provided
41
+ if (!dateStr) {
42
+ console.log('\n🔍 Auto-detecting latest runnable date...');
43
+ dateStr = await tester.findLatestRunnableDate(targetName);
44
+ }
45
+
46
+ // 3. Execute
47
+ console.log(`\n🚀 Executing chain on ${dateStr}...`);
48
+ const output = await tester.run(targetName, dateStr);
49
+
50
+ // 4. Report
51
+ const { summary, result } = output;
52
+
53
+ console.log('\n┌────────────────────────────────────────────────────────┐');
54
+ console.log('│ TEST RESULTS │');
55
+ console.log('└────────────────────────────────────────────────────────┘');
56
+
57
+ if (summary.summary.errors > 0) {
58
+ console.log('❌ Execution FAILED');
59
+ summary.errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
60
+ } else {
61
+ console.log('✅ Execution SUCCESS');
62
+
63
+ // SQL computations: result is { _sqlOutput, outputTable, rowCount, rows }
64
+ if (result && result._sqlOutput) {
65
+ console.log(`\n📊 SQL output table: \`${config.bigquery.dataset}.${result.outputTable}\``);
66
+ console.log(` - Rows for ${dateStr}: ${result.rowCount}`);
67
+ if (result._error) {
68
+ console.log(` - ⚠️ Read error: ${result._error}`);
69
+ } else if (result.rowCount > 0) {
70
+ const sample = result.rows[0];
71
+ console.log(` - Sample row: ${JSON.stringify(sample).substring(0, 120)}...`);
72
+ const filename = `test-results-${targetName}-${dateStr}.json`;
73
+ const outputPath = path.join(__dirname, '..', filename);
74
+ fs.writeFileSync(outputPath, JSON.stringify(result.rows, null, 2));
75
+ console.log(`\n💾 Full rows saved to: ${outputPath}`);
76
+ } else {
77
+ console.log(' - No rows for this date. Check source table has data: portfolio_snapshots for this date.');
78
+ }
79
+ console.log(`\nQuery in BQ: SELECT * FROM \`${config.bigquery.projectId}.${config.bigquery.dataset}.${result.outputTable}\` WHERE date = '${dateStr}'`);
80
+ } else if (result) {
81
+ // Non-SQL: result from computation_results_test
82
+ const resultKeys = Object.keys(result);
83
+ const sampleKey = resultKeys[0];
84
+ const sampleData = resultKeys.length > 0 ? result[sampleKey] : null;
85
+
86
+ console.log(`\n📊 Result Summary for ${targetName}:`);
87
+ console.log(` - Count: ${resultKeys.length} entities/records`);
88
+ console.log(` - Sample ID: ${sampleKey || 'N/A'}`);
89
+ if (sampleData) {
90
+ console.log(` - Sample Data Preview: ${JSON.stringify(sampleData).substring(0, 100)}...`);
91
+ }
92
+ const filename = `test-results-${targetName}-${dateStr}.json`;
93
+ const outputPath = path.join(__dirname, '..', filename);
94
+ fs.writeFileSync(outputPath, JSON.stringify(result, null, 2));
95
+ console.log(`\n💾 Full results saved to: ${outputPath}`);
96
+ console.log(`Query results in BQ: SELECT * FROM \`${config.bigquery.dataset}.computation_results_test\` WHERE computation_name = '${targetName}' AND date = '${dateStr}'`);
97
+ } else {
98
+ console.log('⚠️ No results produced (Computation returned empty set).');
99
+ }
100
+ }
101
+
102
+ } catch (e) {
103
+ console.error('\n❌ FATAL ERROR:');
104
+ console.error(e);
105
+ process.exit(1);
106
+ }
107
+ }
108
+
109
+ main();
@@ -43,7 +43,7 @@ async function storeSignedInUserPortfolio({ db, logger, collectionRegistry, cid,
43
43
  date: date,
44
44
  user_id: Number(cid),
45
45
  user_type: 'SIGNED_IN_USER',
46
- portfolio_data: JSON.stringify(portfolioData), // BigQuery JSON type requires a string
46
+ portfolio_data: portfolioData,
47
47
  fetched_at: new Date().toISOString()
48
48
  };
49
49
 
@@ -103,7 +103,7 @@ async function storeSignedInUserTradeHistory({ db, logger, collectionRegistry, c
103
103
  date: date,
104
104
  user_id: Number(cid),
105
105
  user_type: 'SIGNED_IN_USER',
106
- history_data: JSON.stringify(historyData), // BigQuery JSON type requires a string
106
+ history_data: historyData,
107
107
  fetched_at: new Date().toISOString()
108
108
  };
109
109
 
@@ -172,7 +172,7 @@ async function storeSignedInUserSocialPosts({ db, logger, collectionRegistry, ci
172
172
  date: date,
173
173
  user_id: Number(cid),
174
174
  user_type: 'SIGNED_IN_USER',
175
- posts_data: JSON.stringify({ posts: postsMap, postCount: posts.length }), // BigQuery JSON type requires a string
175
+ posts_data: { posts: postsMap, postCount: posts.length },
176
176
  fetched_at: new Date().toISOString()
177
177
  };
178
178
 
@@ -257,7 +257,7 @@ async function storePopularInvestorPortfolio({ db, logger, collectionRegistry, c
257
257
  date: date,
258
258
  user_id: Number(cid),
259
259
  user_type: 'POPULAR_INVESTOR',
260
- portfolio_data: JSON.stringify(portfolioDoc), // BigQuery JSON type requires a string
260
+ portfolio_data: portfolioDoc,
261
261
  fetched_at: new Date().toISOString()
262
262
  };
263
263
 
@@ -316,7 +316,7 @@ async function storePopularInvestorTradeHistory({ db, logger, collectionRegistry
316
316
  date: date,
317
317
  user_id: Number(cid),
318
318
  user_type: 'POPULAR_INVESTOR',
319
- history_data: JSON.stringify(historyData), // BigQuery JSON type requires a string
319
+ history_data: historyData,
320
320
  fetched_at: new Date().toISOString()
321
321
  };
322
322
 
@@ -383,7 +383,7 @@ async function storePopularInvestorSocialPosts({ db, logger, collectionRegistry,
383
383
  date: date,
384
384
  user_id: Number(cid),
385
385
  user_type: 'POPULAR_INVESTOR',
386
- posts_data: JSON.stringify({ posts: postsMap, postCount: posts.length }), // BigQuery JSON type requires a string
386
+ posts_data: { posts: postsMap, postCount: posts.length },
387
387
  fetched_at: new Date().toISOString()
388
388
  };
389
389
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.765",
3
+ "version": "1.0.768",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -1,176 +0,0 @@
1
- /**
2
- * @fileoverview Popular Investor Risk Assessment - v2
3
- *
4
- * This computation DEPENDS on PopularInvestorProfileMetrics.
5
- * It uses the profile metrics to calculate additional risk assessments.
6
- *
7
- * This tests:
8
- * 1. Dependency declaration works
9
- * 2. Pass assignment is incremented (should be Pass 2)
10
- * 3. Dependency data is loaded and passed to process()
11
- */
12
-
13
- const { Computation } = require('../framework');
14
-
15
- class PopularInvestorRiskAssessment extends Computation {
16
-
17
- static getConfig() {
18
- return {
19
- name: 'PopularInvestorRiskAssessment',
20
- category: 'popular_investor',
21
-
22
- // Only needs rankings for additional risk factors
23
- requires: {
24
- 'pi_rankings': {
25
- lookback: 0,
26
- mandatory: true,
27
- filter: null
28
- }
29
- },
30
-
31
- // DEPENDS ON PopularInvestorProfileMetrics!
32
- dependencies: ['PopularInvestorProfileMetrics'],
33
-
34
- type: 'per-entity',
35
- isHistorical: false,
36
- ttlDays: 30
37
- };
38
- }
39
-
40
- static getSchema() {
41
- return {
42
- type: 'object',
43
- properties: {
44
- riskScore: { type: 'number' },
45
- riskLevel: { type: 'string' },
46
- diversificationScore: { type: 'number' },
47
- volatilityScore: { type: 'number' },
48
- concentrationRisk: { type: 'number' },
49
- sectorConcentration: { type: 'object' },
50
- recommendations: { type: 'array' }
51
- }
52
- };
53
- }
54
-
55
- static getWeight() {
56
- return 3.0;
57
- }
58
-
59
- async process(context) {
60
- const { date, entityId: userId, data, dependencies } = context;
61
-
62
- // Get the profile metrics from our dependency
63
- const profileMetrics = dependencies?.['popularinvestorprofilemetrics']?.[userId];
64
-
65
- // Get rankings data
66
- const rankingsData = data['pi_rankings'];
67
- const rankEntry = this._findRankEntry(rankingsData, userId);
68
-
69
- // Initialize result
70
- const result = {
71
- riskScore: 0,
72
- riskLevel: 'Unknown',
73
- diversificationScore: 0,
74
- volatilityScore: 0,
75
- concentrationRisk: 0,
76
- sectorConcentration: {},
77
- recommendations: []
78
- };
79
-
80
- // If we don't have profile metrics, we can still calculate basic risk from rankings
81
- if (rankEntry) {
82
- const riskScore = rankEntry.RiskScore || rankEntry.riskScore || 0;
83
- result.riskScore = riskScore;
84
- result.riskLevel = this._getRiskLevel(riskScore);
85
- }
86
-
87
- // If we have profile metrics, enhance the assessment
88
- if (profileMetrics) {
89
- // Use sector exposure for diversification analysis
90
- const sectorExposure = profileMetrics.sectorExposure?.data || {};
91
- const sectors = Object.keys(sectorExposure);
92
-
93
- if (sectors.length > 0) {
94
- // Diversification score (more sectors = better diversification)
95
- result.diversificationScore = Math.min(100, sectors.length * 15);
96
-
97
- // Concentration risk (highest sector weight)
98
- const maxExposure = Math.max(...Object.values(sectorExposure), 0);
99
- result.concentrationRisk = maxExposure;
100
-
101
- // Copy sector breakdown
102
- result.sectorConcentration = sectorExposure;
103
-
104
- // Generate recommendations
105
- if (maxExposure > 50) {
106
- result.recommendations.push(`High concentration in single sector (${maxExposure.toFixed(1)}%). Consider diversifying.`);
107
- }
108
- if (sectors.length < 3) {
109
- result.recommendations.push('Portfolio is concentrated in few sectors. Consider adding exposure to other sectors.');
110
- }
111
- }
112
-
113
- // Use portfolio summary for additional risk factors
114
- const portfolioSummary = profileMetrics.portfolioSummary || {};
115
- if (portfolioSummary.profitPercent < -10) {
116
- result.recommendations.push('Portfolio showing significant losses. Review position sizing.');
117
- }
118
-
119
- // Use rankings data from profile for win ratio analysis
120
- const rankingsFromProfile = profileMetrics.rankingsData || {};
121
- if (rankingsFromProfile.winRatio < 0.5) {
122
- result.volatilityScore = 80; // Lower win ratio = higher volatility risk
123
- result.recommendations.push('Win ratio below 50%. Trading strategy may need review.');
124
- } else {
125
- result.volatilityScore = Math.max(0, 100 - (rankingsFromProfile.winRatio * 100));
126
- }
127
-
128
- // Adjust overall risk score based on analysis
129
- const adjustedRisk = (
130
- (result.riskScore * 0.4) +
131
- (result.concentrationRisk * 0.3) +
132
- (result.volatilityScore * 0.3)
133
- );
134
- result.riskScore = Math.round(adjustedRisk);
135
- result.riskLevel = this._getRiskLevel(result.riskScore);
136
- }
137
-
138
- this.setResult(userId, result);
139
- }
140
-
141
- _findRankEntry(rankings, userId) {
142
- if (!rankings) return null;
143
-
144
- // Rankings might be date-keyed or an array
145
- if (typeof rankings === 'object' && !Array.isArray(rankings)) {
146
- const dates = Object.keys(rankings).sort().reverse();
147
- for (const dateStr of dates) {
148
- const dayRankings = rankings[dateStr];
149
- if (Array.isArray(dayRankings)) {
150
- const entry = dayRankings.find(r =>
151
- String(r.CustomerId || r.pi_id || r.userId) === String(userId)
152
- );
153
- if (entry) return entry;
154
- }
155
- }
156
- }
157
-
158
- if (Array.isArray(rankings)) {
159
- return rankings.find(r =>
160
- String(r.CustomerId || r.pi_id || r.userId) === String(userId)
161
- );
162
- }
163
-
164
- return null;
165
- }
166
-
167
- _getRiskLevel(score) {
168
- if (score <= 2) return 'Very Low';
169
- if (score <= 4) return 'Low';
170
- if (score <= 6) return 'Medium';
171
- if (score <= 8) return 'High';
172
- return 'Very High';
173
- }
174
- }
175
-
176
- module.exports = PopularInvestorRiskAssessment;