aiden-shared-calculations-unified 1.0.45 → 1.0.47
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/calculations/meta/social-predictive-regime-state.js +181 -0
- package/calculations/meta/social-topic-driver-index.js +153 -0
- package/calculations/meta/social-topic-predictive-potential.js +455 -0
- package/calculations/pnl/pnl_distribution_per_stock.js +50 -75
- package/calculations/socialPosts/social-topic-sentiment-matrix.js +105 -0
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview NEW CALCULATION (PASS 4 - META)
|
|
3
|
+
* This is the "Quant Regime Engine" or "Master Switch."
|
|
4
|
+
*
|
|
5
|
+
* It determines if the entire class of "social-to-price" signals
|
|
6
|
+
* is currently effective ("ON") or not ("OFF").
|
|
7
|
+
*
|
|
8
|
+
* It is a STATEFUL, ROLLING calculation.
|
|
9
|
+
* 1. It consumes the 'daily_topic_signals' from Pass 3 for the current day.
|
|
10
|
+
* 2. It reads its *own* rolling history of past "breadth" metrics
|
|
11
|
+
* from a single document in Firestore ('social_prediction_regime_state/history').
|
|
12
|
+
* 3. It calculates the "predictive breadth" for the current day:
|
|
13
|
+
* (What % of all topics analyzed had a Predictive Potential > threshold?)
|
|
14
|
+
* 4. It appends this new breadth metric to its history.
|
|
15
|
+
* 5. It calculates a short-term and long-term EMA of this breadth metric.
|
|
16
|
+
* 6. It uses the EMA crossover to determine the `regime_state`.
|
|
17
|
+
* 7. It returns its updated state AND the daily regime signal.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { FieldValue } = require('@google-cloud/firestore');
|
|
21
|
+
|
|
22
|
+
// --- CONFIGURATION ---
|
|
23
|
+
// The single doc where this calc stores its rolling history
|
|
24
|
+
const STATE_DOC_PATH = 'social_prediction_regime_state/history';
|
|
25
|
+
const ROLLING_HISTORY_DAYS = 90; // History for EMAs
|
|
26
|
+
const EMA_SHORT_PERIOD = 3; // 3-day EMA
|
|
27
|
+
const EMA_LONG_PERIOD = 14; // 14-day EMA
|
|
28
|
+
// How far apart the EMAs must be to declare a firm regime
|
|
29
|
+
const REGIME_THRESHOLD = 0.02; // e.g., 2% difference
|
|
30
|
+
// The Predictive Potential (from Pass 3) threshold to be counted
|
|
31
|
+
const PP_THRESHOLD = 0.5;
|
|
32
|
+
|
|
33
|
+
// --- STATS HELPER ---
|
|
34
|
+
/**
|
|
35
|
+
* Calculates a simple Exponential Moving Average (EMA) from an array of values.
|
|
36
|
+
* @param {number[]} data - Array of numbers (oldest to newest).
|
|
37
|
+
* @param {number} period - The EMA period.
|
|
38
|
+
* @returns {number|null} The final EMA value, or null if not enough data.
|
|
39
|
+
*/
|
|
40
|
+
function _calculateEMA(data, period) {
|
|
41
|
+
if (data.length < period) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const k = 2 / (period + 1); // Smoothing factor
|
|
45
|
+
let ema = data[0]; // Start with the first value
|
|
46
|
+
for (let i = 1; i < data.length; i++) {
|
|
47
|
+
ema = (data[i] * k) + (ema * (1 - k));
|
|
48
|
+
}
|
|
49
|
+
return ema;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SocialPredictiveRegimeState {
|
|
54
|
+
|
|
55
|
+
static getDependencies() {
|
|
56
|
+
// Depends on the *daily output* from Pass 3
|
|
57
|
+
return ['social-topic-predictive-potential'];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
constructor() {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} dateStr The date to run the analysis for (e.g., "2025-11-06").
|
|
64
|
+
* @param {object} dependencies The shared dependencies (db, logger).
|
|
65
|
+
* @param {object} config The computation system configuration.
|
|
66
|
+
* @param {object} fetchedDependencies In-memory results from Pass 3.
|
|
67
|
+
* @returns {Promise<object|null>} The analysis result or null.
|
|
68
|
+
*/
|
|
69
|
+
async process(dateStr, dependencies, config, fetchedDependencies) {
|
|
70
|
+
const { db, logger } = dependencies;
|
|
71
|
+
|
|
72
|
+
// 1. Get Pass 3 Dependency
|
|
73
|
+
const pass3_output = fetchedDependencies['social-topic-predictive-potential'];
|
|
74
|
+
if (!pass3_output || !pass3_output.daily_topic_signals) {
|
|
75
|
+
logger.log('WARN', `[SocialRegime] Missing or empty dependency 'social-topic-predictive-potential' for ${dateStr}. Skipping.`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const daily_signals = pass3_output.daily_topic_signals;
|
|
79
|
+
|
|
80
|
+
// 2. Calculate Today's "Predictive Breadth"
|
|
81
|
+
let totalTopicsAnalyzed = 0;
|
|
82
|
+
let totalPredictiveTopics = 0;
|
|
83
|
+
|
|
84
|
+
for (const ticker in daily_signals) {
|
|
85
|
+
const allTopics = [
|
|
86
|
+
...(daily_signals[ticker].topPredictiveBullishTopics || []),
|
|
87
|
+
...(daily_signals[ticker].topPredictiveBearishTopics || [])
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
for (const topic of allTopics) {
|
|
91
|
+
totalTopicsAnalyzed++;
|
|
92
|
+
// Check if this topic is "predictive"
|
|
93
|
+
if (topic.predictivePotential > PP_THRESHOLD) {
|
|
94
|
+
// Check if it has at least one stable window
|
|
95
|
+
const hasStableWindow = Object.values(topic.correlations).some(
|
|
96
|
+
corr => corr.samples >= MIN_SAMPLE_COUNT && Math.abs(corr.value) > CORR_THRESHOLD
|
|
97
|
+
);
|
|
98
|
+
if (hasStableWindow) {
|
|
99
|
+
totalPredictiveTopics++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const todayBreadth = (totalTopicsAnalyzed > 0)
|
|
106
|
+
? (totalPredictiveTopics / totalTopicsAnalyzed)
|
|
107
|
+
: 0; // 0% breadth if no topics found
|
|
108
|
+
|
|
109
|
+
// 3. Load State, Update, and Save (all in one transaction)
|
|
110
|
+
|
|
111
|
+
let dailyOutput = {}; // The daily signal for the API
|
|
112
|
+
let stateToSave = {}; // The new state to save back to Firestore
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await db.runTransaction(async (transaction) => {
|
|
116
|
+
const docRef = db.doc(STATE_DOC_PATH);
|
|
117
|
+
const doc = await transaction.get(docRef);
|
|
118
|
+
|
|
119
|
+
// 4a. Load and update history
|
|
120
|
+
const history = doc.exists ? (doc.data().history || []) : [];
|
|
121
|
+
history.push({ date: dateStr, breadth: todayBreadth });
|
|
122
|
+
|
|
123
|
+
// 4b. Prune history
|
|
124
|
+
const prunedHistory = history.slice(-ROLLING_HISTORY_DAYS);
|
|
125
|
+
|
|
126
|
+
// 4c. Calculate EMAs
|
|
127
|
+
const breadthValues = prunedHistory.map(h => h.breadth);
|
|
128
|
+
const emaShort = _calculateEMA(breadthValues, EMA_SHORT_PERIOD);
|
|
129
|
+
const emaLong = _calculateEMA(breadthValues, EMA_LONG_PERIOD);
|
|
130
|
+
|
|
131
|
+
// 4d. Determine Regime State
|
|
132
|
+
let regimeState = "TRANSITIONING";
|
|
133
|
+
if (emaShort !== null && emaLong !== null) {
|
|
134
|
+
const diff = emaShort - emaLong;
|
|
135
|
+
if (diff > REGIME_THRESHOLD) {
|
|
136
|
+
regimeState = "ON";
|
|
137
|
+
} else if (diff < -REGIME_THRESHOLD) {
|
|
138
|
+
regimeState = "OFF";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 4e. Prepare data to save and return
|
|
143
|
+
stateToSave = {
|
|
144
|
+
history: prunedHistory,
|
|
145
|
+
lastUpdated: FieldValue.serverTimestamp()
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
dailyOutput = {
|
|
149
|
+
social_predictive_regime_strength: emaShort, // The "fast" signal
|
|
150
|
+
regime_state: regimeState,
|
|
151
|
+
components: {
|
|
152
|
+
today_breadth_pct: todayBreadth,
|
|
153
|
+
ema_short: emaShort,
|
|
154
|
+
ema_long: emaLong,
|
|
155
|
+
threshold: REGIME_THRESHOLD
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// 4f. Save state back to Firestore
|
|
160
|
+
transaction.set(docRef, stateToSave);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
} catch (error) {
|
|
164
|
+
logger.log('ERROR', `[SocialRegime] Transaction failed for ${dateStr}`, { err: error.message });
|
|
165
|
+
return null; // Don't return partial data
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 5. Return both state (for sharding) and daily signal (for API)
|
|
169
|
+
return {
|
|
170
|
+
// This key name must be unique
|
|
171
|
+
sharded_regime_state: { [STATE_DOC_PATH]: stateToSave },
|
|
172
|
+
// This is the daily result for unified_insights
|
|
173
|
+
daily_regime_signal: dailyOutput
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async getResult() { return null; } // Logic is in process()
|
|
178
|
+
reset() {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = SocialPredictiveRegimeState;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview NEW CALCULATION (PASS 2 - META)
|
|
3
|
+
* This is the advanced signal calculation. It consumes the
|
|
4
|
+
* 'social-topic-sentiment-matrix' from Pass 1, loads the
|
|
5
|
+
* daily price change for each asset, and then determines
|
|
6
|
+
* the primary bullish and bearish topics driving the conversation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// We need calculation utils to load price and mapping data
|
|
10
|
+
const { loadAllPriceData, getDailyPriceChange } = require('../../utils/price_data_provider');
|
|
11
|
+
const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
|
|
12
|
+
|
|
13
|
+
class SocialTopicDriverIndex {
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Statically declare dependencies.
|
|
17
|
+
* This will run in Pass 2, after the matrix is built.
|
|
18
|
+
*/
|
|
19
|
+
static getDependencies() {
|
|
20
|
+
return ['social-topic-sentiment-matrix'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
this.priceMap = null;
|
|
25
|
+
this.tickerToIdMap = null; // For { "AAPL" -> 123 }
|
|
26
|
+
this.dependenciesLoaded = false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Helper to load price/mapping data on the first run.
|
|
31
|
+
*/
|
|
32
|
+
async _loadDependencies(calculationUtils) {
|
|
33
|
+
if (this.dependenciesLoaded) return;
|
|
34
|
+
|
|
35
|
+
const [priceData, mappings] = await Promise.all([
|
|
36
|
+
calculationUtils.loadAllPriceData(),
|
|
37
|
+
calculationUtils.loadInstrumentMappings()
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
this.priceMap = priceData;
|
|
41
|
+
|
|
42
|
+
// Create the Ticker -> InstrumentID map
|
|
43
|
+
this.tickerToIdMap = {};
|
|
44
|
+
if (mappings && mappings.instrumentToTicker) {
|
|
45
|
+
for (const [id, ticker] of Object.entries(mappings.instrumentToTicker)) {
|
|
46
|
+
this.tickerToIdMap[ticker] = id;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.dependenciesLoaded = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Helper to get the previous day's date string.
|
|
55
|
+
*/
|
|
56
|
+
_getYesterday(dateStr) {
|
|
57
|
+
const date = new Date(dateStr + 'T00:00:00Z');
|
|
58
|
+
date.setUTCDate(date.getUTCDate() - 1);
|
|
59
|
+
return date.toISOString().slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} dateStr The date to run the analysis for (e.g., "2025-11-05").
|
|
64
|
+
* @param {object} dependencies The shared dependencies (db, logger, calculationUtils).
|
|
65
|
+
* @param {object} config The computation system configuration.
|
|
66
|
+
* @param {object} fetchedDependencies In-memory results from Pass 1.
|
|
67
|
+
* e.g., { 'social-topic-sentiment-matrix': { "AAPL": { ... } } }
|
|
68
|
+
* @returns {Promise<object|null>} The analysis result or null.
|
|
69
|
+
*/
|
|
70
|
+
async process(dateStr, dependencies, config, fetchedDependencies) {
|
|
71
|
+
const { logger, calculationUtils } = dependencies;
|
|
72
|
+
|
|
73
|
+
// 1. Load dependencies
|
|
74
|
+
await this._loadDependencies(calculationUtils);
|
|
75
|
+
const matrix = fetchedDependencies['social-topic-sentiment-matrix'];
|
|
76
|
+
|
|
77
|
+
if (!matrix || Object.keys(matrix).length === 0) {
|
|
78
|
+
logger.log('WARN', `[SocialTopicDriverIndex] Missing or empty dependency 'social-topic-sentiment-matrix' for ${dateStr}. Skipping.`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!this.priceMap || !this.tickerToIdMap) {
|
|
83
|
+
logger.log('ERROR', `[SocialTopicDriverIndex] Price map or Ticker map failed to load. Aborting.`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const yesterdayStr = this._getYesterday(dateStr); // e.g., "2025-11-04"
|
|
88
|
+
const finalResults = {};
|
|
89
|
+
|
|
90
|
+
// 2. Correlate for each ticker
|
|
91
|
+
for (const ticker in matrix) {
|
|
92
|
+
const topicData = matrix[ticker];
|
|
93
|
+
const instrumentId = this.tickerToIdMap[ticker];
|
|
94
|
+
|
|
95
|
+
if (!instrumentId) {
|
|
96
|
+
logger.log('TRACE', `[SocialTopicDriverIndex] Skipping ${ticker}, no instrumentId found.`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Get Price Change
|
|
101
|
+
// This correctly uses the resilient _findPreviousAvailablePrice helper
|
|
102
|
+
const priceChangePct = getDailyPriceChange(instrumentId, yesterdayStr, dateStr, this.priceMap);
|
|
103
|
+
|
|
104
|
+
if (priceChangePct === null) {
|
|
105
|
+
logger.log('TRACE', `[SocialTopicDriverIndex] Skipping ${ticker}, no price data found for ${dateStr}.`);
|
|
106
|
+
continue; // Skip if we can't get price data
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Analyze topics for this ticker
|
|
110
|
+
const topicDrivers = [];
|
|
111
|
+
for (const topic in topicData) {
|
|
112
|
+
const data = topicData[topic];
|
|
113
|
+
const totalSentientPosts = data.bullishPosts + data.bearishPosts;
|
|
114
|
+
|
|
115
|
+
let sentimentScore = 0;
|
|
116
|
+
if (totalSentientPosts > 0) {
|
|
117
|
+
sentimentScore = (data.bullishPosts - data.bearishPosts) / totalSentientPosts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
topicDrivers.push({
|
|
121
|
+
topic: topic,
|
|
122
|
+
sentimentScore: sentimentScore, // -1 (Bearish) to +1 (Bullish)
|
|
123
|
+
convictionScore: data.convictionScore,
|
|
124
|
+
totalPosts: data.totalPosts,
|
|
125
|
+
bullishPosts: data.bullishPosts,
|
|
126
|
+
bearishPosts: data.bearishPosts
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 5. Find top drivers by conviction
|
|
131
|
+
// Sort by most conviction (likes/comments)
|
|
132
|
+
topicDrivers.sort((a, b) => b.convictionScore - a.convictionScore);
|
|
133
|
+
|
|
134
|
+
// Find the most-discussed bullish and bearish topics
|
|
135
|
+
const topBullishDriver = topicDrivers.find(d => d.sentimentScore > 0.1) || null;
|
|
136
|
+
const topBearishDriver = topicDrivers.find(d => d.sentimentScore < -0.1) || null;
|
|
137
|
+
|
|
138
|
+
finalResults[ticker] = {
|
|
139
|
+
priceChangePct: priceChangePct,
|
|
140
|
+
topBullishDriver: topBullishDriver,
|
|
141
|
+
topBearishDriver: topBearishDriver,
|
|
142
|
+
allDrivers: topicDrivers // Store all for potential future analysis
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return finalResults;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getResult() { return null; } // This is a meta-calc, logic is in process()
|
|
150
|
+
reset() {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = SocialTopicDriverIndex;
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview NEW CALCULATION (PASS 3 - META)
|
|
3
|
+
* This is the "Quant" signal discovery engine.
|
|
4
|
+
*
|
|
5
|
+
* This is a stateful, rolling calculation that implements several
|
|
6
|
+
* critical optimizations based on production-level review:
|
|
7
|
+
* 1. It uses `p-limit` to run concurrent transactions safely.
|
|
8
|
+
* 2. It returns state directly from transactions, avoiding costly re-reads.
|
|
9
|
+
* 3. It uses a forward-looking price helper (`_findPriceForward`)
|
|
10
|
+
* to be resilient to market holidays and missing data.
|
|
11
|
+
* 4. All logic is factored into testable helper functions.
|
|
12
|
+
*
|
|
13
|
+
* --- V3 MODIFICATION (Quant Upgrade) ---
|
|
14
|
+
* 5. Implements RECENCY WEIGHTING on the correlation calculation.
|
|
15
|
+
* Signals from recent days are given exponentially more weight
|
|
16
|
+
* than signals from 90 days ago, per standard quant practice.
|
|
17
|
+
* This is achieved by upgrading _calculatePearson to
|
|
18
|
+
* _calculateWeightedPearson.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { FieldValue } = require('@google-cloud/firestore');
|
|
22
|
+
// p-limit is a non-standard-lib, but is included in the bulltrackers-module
|
|
23
|
+
// and available via the root `index.js` dependency injection.
|
|
24
|
+
// We will assume it's passed in via dependencies.calculationUtils.pLimit
|
|
25
|
+
const pLimit = require('p-limit');
|
|
26
|
+
|
|
27
|
+
// Import all required utils
|
|
28
|
+
const { loadAllPriceData, getDailyPriceChange } = require('../../utils/price_data_provider');
|
|
29
|
+
const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
|
|
30
|
+
|
|
31
|
+
// --- CONFIGURATION ---
|
|
32
|
+
const SHARD_COLLECTION_NAME = 'social_topic_rolling_stats';
|
|
33
|
+
const ROLLING_HISTORY_DAYS = 90;
|
|
34
|
+
const MIN_SAMPLE_COUNT = 12; // Min samples needed to trust a correlation
|
|
35
|
+
const CORR_THRESHOLD = 0.25; // Min abs correlation to be considered "stable"
|
|
36
|
+
const FORWARD_WINDOWS = [1, 3, 7, 21]; // [1d, 3d, 7d, 21d]
|
|
37
|
+
const MAX_CONCURRENT_TRANSACTIONS = 50; // Max parallel Firestore transactions
|
|
38
|
+
const MAX_LOOKAHEAD_DAYS = 7; // How far to look for a non-holiday price
|
|
39
|
+
const WEIGHTING_DECAY_K = 3.0; // Decay factor for recency weighting (e.g., k=3.0)
|
|
40
|
+
|
|
41
|
+
// --- STATS HELPER ---
|
|
42
|
+
/**
|
|
43
|
+
* --- MODIFIED: Upgraded to Weighted Pearson Correlation ---
|
|
44
|
+
* Calculates the Pearson correlation coefficient for two arrays,
|
|
45
|
+
* weighted by a third array.
|
|
46
|
+
* Gracefully handles nulls by only using paired data.
|
|
47
|
+
*/
|
|
48
|
+
function _calculateWeightedPearson(vecX, vecY, vecWeights) {
|
|
49
|
+
let sumW = 0;
|
|
50
|
+
let sumWX = 0;
|
|
51
|
+
let sumWY = 0;
|
|
52
|
+
let validPairs = []; // To store non-null pairs
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < vecX.length; i++) {
|
|
55
|
+
const x = vecX[i];
|
|
56
|
+
const y = vecY[i];
|
|
57
|
+
const w = vecWeights[i]; // Get the weight
|
|
58
|
+
|
|
59
|
+
// Only use pairs where both values are valid numbers and weight is positive
|
|
60
|
+
if (x !== null && y !== null && isFinite(x) && isFinite(y) && w > 0) {
|
|
61
|
+
sumW += w;
|
|
62
|
+
sumWX += w * x;
|
|
63
|
+
sumWY += w * y;
|
|
64
|
+
validPairs.push({x, y, w});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Need at least 2 data points and positive total weight
|
|
69
|
+
if (sumW === 0 || validPairs.length < 2) {
|
|
70
|
+
return { value: 0, samples: validPairs.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Calculate weighted means
|
|
74
|
+
const meanX = sumWX / sumW;
|
|
75
|
+
const meanY = sumWY / sumW;
|
|
76
|
+
|
|
77
|
+
let sumCov = 0;
|
|
78
|
+
let sumVarX = 0;
|
|
79
|
+
let sumVarY = 0;
|
|
80
|
+
|
|
81
|
+
// Second pass to calculate weighted covariance and variances
|
|
82
|
+
for (const pair of validPairs) {
|
|
83
|
+
const { x, y, w } = pair;
|
|
84
|
+
sumCov += w * (x - meanX) * (y - meanY);
|
|
85
|
+
sumVarX += w * (x - meanX) * (x - meanX);
|
|
86
|
+
sumVarY += w * (y - meanY) * (y - meanY);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const denominator = Math.sqrt(sumVarX * sumVarY);
|
|
90
|
+
|
|
91
|
+
if (denominator === 0) {
|
|
92
|
+
return { value: 0, samples: validPairs.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The weighted correlation is cov(x,y) / (std(x) * std(y))
|
|
96
|
+
// The sumW terms cancel out from the numerator and denominator.
|
|
97
|
+
const corr = sumCov / denominator;
|
|
98
|
+
|
|
99
|
+
// Clamp correlation to valid range [-1, 1] to handle floating point errors
|
|
100
|
+
return { value: Math.max(-1, Math.min(1, corr)), samples: validPairs.length };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- DATE HELPERS ---
|
|
104
|
+
function _getDateStr(baseDate, daysOffset) {
|
|
105
|
+
const date = new Date(baseDate); // baseDate is Date object or string
|
|
106
|
+
date.setUTCDate(date.getUTCDate() + daysOffset);
|
|
107
|
+
return date.toISOString().slice(0, 10);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- PRICE HELPERS ---
|
|
111
|
+
/**
|
|
112
|
+
* Resiliently finds a price for a given date from the price map.
|
|
113
|
+
* (Looks *backward* for last available price)
|
|
114
|
+
*/
|
|
115
|
+
function _findPrice(instrumentId, dateStr, priceMap) {
|
|
116
|
+
if (!priceMap || !priceMap[instrumentId]) return null;
|
|
117
|
+
const priceHistory = priceMap[instrumentId];
|
|
118
|
+
|
|
119
|
+
let checkDate = new Date(dateStr + 'T00:00:00Z');
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < MAX_LOOKAHEAD_DAYS; i++) {
|
|
122
|
+
const checkDateStr = checkDate.toISOString().slice(0, 10);
|
|
123
|
+
const price = priceHistory[checkDateStr];
|
|
124
|
+
|
|
125
|
+
if (price !== undefined && price !== null && price > 0) return price;
|
|
126
|
+
checkDate.setUTCDate(checkDate.getUTCDate() - 1);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* NEW: Resiliently finds the next available price *on or after* a given date.
|
|
133
|
+
* (Looks *forward* to handle holidays/weekends)
|
|
134
|
+
*/
|
|
135
|
+
function _findPriceForward(instrumentId, dateStr, priceMap) {
|
|
136
|
+
if (!priceMap || !priceMap[instrumentId]) return null;
|
|
137
|
+
const priceHistory = priceMap[instrumentId];
|
|
138
|
+
|
|
139
|
+
let checkDate = new Date(dateStr + 'T00:00:00Z');
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i <= MAX_LOOKAHEAD_DAYS; i++) {
|
|
142
|
+
const checkDateStr = checkDate.toISOString().slice(0, 10);
|
|
143
|
+
const price = priceHistory[checkDateStr];
|
|
144
|
+
|
|
145
|
+
if (price !== undefined && price !== null && price > 0) return price;
|
|
146
|
+
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SocialTopicPredictivePotentialIndex {
|
|
153
|
+
|
|
154
|
+
static getDependencies() {
|
|
155
|
+
return ['social-topic-driver-index'];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
constructor() {
|
|
159
|
+
this.priceMap = null;
|
|
160
|
+
this.tickerToIdMap = null;
|
|
161
|
+
this.dependenciesLoaded = false;
|
|
162
|
+
// Concurrency limiter for Firestore transactions
|
|
163
|
+
this.pLimit = pLimit(MAX_CONCURRENT_TRANSACTIONS);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async _loadDependencies(calculationUtils) {
|
|
167
|
+
if (this.dependenciesLoaded) return;
|
|
168
|
+
|
|
169
|
+
const [priceData, mappings] = await Promise.all([
|
|
170
|
+
calculationUtils.loadAllPriceData(),
|
|
171
|
+
calculationUtils.loadInstrumentMappings()
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
this.priceMap = priceData;
|
|
175
|
+
this.tickerToIdMap = {};
|
|
176
|
+
if (mappings && mappings.instrumentToTicker) {
|
|
177
|
+
for (const [id, ticker] of Object.entries(mappings.instrumentToTicker)) {
|
|
178
|
+
this.tickerToIdMap[ticker] = id;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.dependenciesLoaded = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} dateStr The date to run the analysis for (e.g., "2025-11-05").
|
|
186
|
+
* @param {object} dependencies The shared dependencies (db, logger, calculationUtils).
|
|
187
|
+
* @param {object} config The computation system configuration.
|
|
188
|
+
* @param {object} fetchedDependencies In-memory results from Pass 2.
|
|
189
|
+
* @returns {Promise<object|null>} The analysis result or null.
|
|
190
|
+
*/
|
|
191
|
+
async process(dateStr, dependencies, config, fetchedDependencies) {
|
|
192
|
+
const { db, logger, calculationUtils } = dependencies;
|
|
193
|
+
|
|
194
|
+
// 1. Load all dependencies
|
|
195
|
+
// pLimit is not in calculationUtils by default, so we'll use our own
|
|
196
|
+
// If it were, we'd use: this.pLimit = calculationUtils.pLimit(MAX_CONCURRENT_TRANSACTIONS);
|
|
197
|
+
await this._loadDependencies(calculationUtils);
|
|
198
|
+
const todaySignals = fetchedDependencies['social-topic-driver-index'];
|
|
199
|
+
|
|
200
|
+
if (!todaySignals || Object.keys(todaySignals).length === 0) {
|
|
201
|
+
logger.log('WARN', `[SocialTopicPredictive] Missing or empty dependency 'social-topic-driver-index' for ${dateStr}. Skipping.`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!this.priceMap || !this.tickerToIdMap) {
|
|
206
|
+
logger.log('ERROR', `[SocialTopicPredictive] Price map or Ticker map failed to load. Aborting.`);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- Prepare final output objects ---
|
|
211
|
+
const shardedData = {};
|
|
212
|
+
const dailyOutput = {};
|
|
213
|
+
|
|
214
|
+
const allTickers = Object.keys(todaySignals);
|
|
215
|
+
|
|
216
|
+
// 2. Run all ticker updates in parallel, limited by pLimit
|
|
217
|
+
const transactionPromises = allTickers.map(ticker =>
|
|
218
|
+
this.pLimit(() => this._processTickerTransaction(
|
|
219
|
+
ticker,
|
|
220
|
+
dateStr,
|
|
221
|
+
todaySignals[ticker] || {},
|
|
222
|
+
dependencies
|
|
223
|
+
))
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const txResults = await Promise.all(transactionPromises);
|
|
227
|
+
|
|
228
|
+
// 3. Collect results from transactions (FIX A)
|
|
229
|
+
for (const res of txResults) {
|
|
230
|
+
if (!res) continue; // Transaction may have failed and returned null
|
|
231
|
+
shardedData[res.ticker] = res.state;
|
|
232
|
+
dailyOutput[res.ticker] = res.dailyOutputForTicker;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 4. Return both the state (for sharded write) and daily signal (for API)
|
|
236
|
+
return {
|
|
237
|
+
sharded_social_stats: { [SHARD_COLLECTION_NAME]: shardedData },
|
|
238
|
+
daily_topic_signals: dailyOutput
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* This is the core logic, run for a single ticker *inside* a transaction.
|
|
244
|
+
* It reads, modifies, and writes state for one ticker, then returns
|
|
245
|
+
* the new state and daily signal.
|
|
246
|
+
*/
|
|
247
|
+
async _processTickerTransaction(ticker, dateStr, todaySignal, dependencies) {
|
|
248
|
+
const { db, logger } = dependencies;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
return await db.runTransaction(async (transaction) => {
|
|
252
|
+
const instrumentId = this.tickerToIdMap[ticker];
|
|
253
|
+
if (!instrumentId) return null;
|
|
254
|
+
|
|
255
|
+
const todayPrice = _findPrice(instrumentId, dateStr, this.priceMap);
|
|
256
|
+
if (todayPrice === null) return null; // Cannot update returns
|
|
257
|
+
|
|
258
|
+
// --- 4a. Load State ---
|
|
259
|
+
const docRef = db.collection(SHARD_COLLECTION_NAME).doc(ticker);
|
|
260
|
+
const doc = await transaction.get(docRef);
|
|
261
|
+
const state = doc.exists ? doc.data() : {};
|
|
262
|
+
|
|
263
|
+
// --- 4b. Update Forward Returns (Factored Helper) ---
|
|
264
|
+
this._updateForwardReturns(state, instrumentId, dateStr, todayPrice, this.priceMap);
|
|
265
|
+
|
|
266
|
+
// --- 4c. Add New Signals (Factored Helper) ---
|
|
267
|
+
this._addNewSignals(state, todaySignal.allDrivers || [], dateStr);
|
|
268
|
+
|
|
269
|
+
// --- 4d. Recalculate Correlations (Factored Helper) ---
|
|
270
|
+
const dailyOutputForTicker = this._recalculateAllTopics(state, logger);
|
|
271
|
+
|
|
272
|
+
// --- 4e. Save State (FIX 3.2: Use merge) ---
|
|
273
|
+
transaction.set(docRef, state, { merge: true });
|
|
274
|
+
|
|
275
|
+
// --- 4f. Return (FIX A) ---
|
|
276
|
+
return { ticker, state, dailyOutputForTicker };
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logger.log('ERROR', `[SocialTopicPredictive] Transaction failed for ticker ${ticker}`, { err: error.message, stack: error.stack });
|
|
280
|
+
return null; // Return null on transaction failure
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Helper to update past forward-return windows.
|
|
286
|
+
* Modifies the `state` object in-place.
|
|
287
|
+
*/
|
|
288
|
+
_updateForwardReturns(state, instrumentId, dateStr, todayPrice, priceMap) {
|
|
289
|
+
for (const topic in state) {
|
|
290
|
+
if (!state[topic].rolling_90d_history) continue;
|
|
291
|
+
|
|
292
|
+
for (const historyEntry of state[topic].rolling_90d_history) {
|
|
293
|
+
const signalPrice = _findPrice(instrumentId, historyEntry.date, priceMap);
|
|
294
|
+
if (signalPrice === null) continue;
|
|
295
|
+
|
|
296
|
+
// FIX D: Check all windows
|
|
297
|
+
for (const window of FORWARD_WINDOWS) {
|
|
298
|
+
// Skip if already filled
|
|
299
|
+
if (historyEntry[`fwd_${window}d`] !== null) continue;
|
|
300
|
+
|
|
301
|
+
const targetDateStr = _getDateStr(new Date(historyEntry.date + 'T00:00:00Z'), window);
|
|
302
|
+
|
|
303
|
+
// Not yet time to fill this window
|
|
304
|
+
if (dateStr < targetDateStr) continue;
|
|
305
|
+
|
|
306
|
+
// FIX C: Use _findPriceForward
|
|
307
|
+
const targetPrice = _findPriceForward(instrumentId, targetDateStr, priceMap);
|
|
308
|
+
|
|
309
|
+
if (targetPrice !== null) {
|
|
310
|
+
historyEntry[`fwd_${window}d`] = (targetPrice / signalPrice) - 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Helper to add the new day's signals to the state.
|
|
319
|
+
* Modifies the `state` object in-place.
|
|
320
|
+
*/
|
|
321
|
+
_addNewSignals(state, newTickerSignals, dateStr) {
|
|
322
|
+
for (const newSignal of newTickerSignals) {
|
|
323
|
+
// FIX B: Normalize topic key
|
|
324
|
+
const topic = (newSignal.topic || 'untagged').toLowerCase();
|
|
325
|
+
|
|
326
|
+
if (!state[topic]) {
|
|
327
|
+
state[topic] = {
|
|
328
|
+
rolling_90d_history: [],
|
|
329
|
+
correlations: {},
|
|
330
|
+
predictivePotential: 0,
|
|
331
|
+
avgDailyConviction: 0
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
state[topic].rolling_90d_history.push({
|
|
336
|
+
date: dateStr,
|
|
337
|
+
sentimentScore: newSignal.sentimentScore,
|
|
338
|
+
conviction: newSignal.convictionScore,
|
|
339
|
+
fwd_1d: null, fwd_3d: null, fwd_7d: null, fwd_21d: null,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Prune history
|
|
343
|
+
state[topic].rolling_90d_history = state[topic].rolling_90d_history.slice(-ROLLING_HISTORY_DAYS);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Helper to recalculate all stats for a ticker's topics.
|
|
349
|
+
* Modifies the `state` object in-place.
|
|
350
|
+
* @returns {object} The dailyOutputForTicker
|
|
351
|
+
*/
|
|
352
|
+
_recalculateAllTopics(state, logger) {
|
|
353
|
+
const predictiveTopics = [];
|
|
354
|
+
|
|
355
|
+
for (const topic in state) {
|
|
356
|
+
const history = state[topic].rolling_90d_history;
|
|
357
|
+
if (!history || history.length === 0) continue;
|
|
358
|
+
|
|
359
|
+
// FIX G: Warn if doc size is at risk
|
|
360
|
+
if (history.length > 500) { // arbitrary high number
|
|
361
|
+
logger.log('WARN', `[SocialTopicPredictive] Topic "${topic}" has ${history.length} history entries. May approach doc size limit.`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// FIX E: Ensure sort order
|
|
365
|
+
history.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
366
|
+
|
|
367
|
+
// --- START V3 MODIFICATION: RECENCY WEIGHTING ---
|
|
368
|
+
const N = history.length;
|
|
369
|
+
if (N === 0) continue;
|
|
370
|
+
|
|
371
|
+
// Create recency weights (exponential decay)
|
|
372
|
+
// Newest item (i=N-1) gets weight 1.0 (age=0)
|
|
373
|
+
// Oldest item (i=0) gets weight exp(-K) (age=N-1)
|
|
374
|
+
const vecWeights = history.map((h, i) => {
|
|
375
|
+
const age_in_days = N - 1 - i; // 0 for newest, N-1 for oldest
|
|
376
|
+
// Normalize age by history length so K is consistent
|
|
377
|
+
return Math.exp(-WEIGHTING_DECAY_K * age_in_days / N);
|
|
378
|
+
});
|
|
379
|
+
// --- END V3 MODIFICATION ---
|
|
380
|
+
|
|
381
|
+
const vecSentiment = history.map(h => h.sentimentScore);
|
|
382
|
+
const vecConviction = history.map(h => h.conviction); // Used for PP score
|
|
383
|
+
|
|
384
|
+
let totalPP = 0;
|
|
385
|
+
let windowsCounted = 0;
|
|
386
|
+
let totalStableWindows = 0;
|
|
387
|
+
|
|
388
|
+
for (const window of FORWARD_WINDOWS) {
|
|
389
|
+
const vecForwardReturn = history.map(h => h[`fwd_${window}d`]);
|
|
390
|
+
|
|
391
|
+
// --- V3 MODIFICATION ---
|
|
392
|
+
// Use the new weighted Pearson calculation
|
|
393
|
+
const corr = _calculateWeightedPearson(vecSentiment, vecForwardReturn, vecWeights);
|
|
394
|
+
// --- END V3 MODIFICATION ---
|
|
395
|
+
|
|
396
|
+
state[topic].correlations[`fwd_${window}d`] = corr; // Save { value, samples }
|
|
397
|
+
|
|
398
|
+
if (corr.samples >= MIN_SAMPLE_COUNT) {
|
|
399
|
+
totalPP += Math.abs(corr.value);
|
|
400
|
+
windowsCounted++;
|
|
401
|
+
if (Math.abs(corr.value) > CORR_THRESHOLD) {
|
|
402
|
+
totalStableWindows++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let ppScore = 0;
|
|
408
|
+
if (windowsCounted > 0) {
|
|
409
|
+
ppScore = totalPP / windowsCounted;
|
|
410
|
+
|
|
411
|
+
const avgConviction = state[topic].rolling_90d_history.reduce((acc, h) => acc + h.conviction, 0) / history.length;
|
|
412
|
+
state[topic].avgDailyConviction = avgConviction;
|
|
413
|
+
|
|
414
|
+
// FIX F: Clamp before log
|
|
415
|
+
ppScore *= Math.log(1 + Math.max(0, avgConviction));
|
|
416
|
+
|
|
417
|
+
ppScore *= (totalStableWindows / windowsCounted);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
state[topic].predictivePotential = ppScore;
|
|
421
|
+
|
|
422
|
+
predictiveTopics.push({
|
|
423
|
+
topic: topic,
|
|
424
|
+
predictivePotential: ppScore,
|
|
425
|
+
sentimentScore: vecSentiment[vecSentiment.length - 1], // Latest sentiment
|
|
426
|
+
correlations: state[topic].correlations
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- Generate Daily Output ---
|
|
431
|
+
predictiveTopics.sort((a, b) => b.predictivePotential - a.predictivePotential);
|
|
432
|
+
const confidence = predictiveTopics.length > 0 ? predictiveTopics[0].predictivePotential : 0;
|
|
433
|
+
const normalizedConfidence = Math.min(1, confidence / 10); // Simple normalization
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
topPredictiveBullishTopics: predictiveTopics
|
|
437
|
+
.filter(t => t.sentimentScore > 0.1)
|
|
438
|
+
.slice(0, 5),
|
|
439
|
+
topPredictiveBearishTopics: predictiveTopics
|
|
440
|
+
.filter(t => t.sentimentScore < -0.1)
|
|
441
|
+
.slice(0, 5),
|
|
442
|
+
predictiveConfidenceScore: normalizedConfidence
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async getResult() { return null; } // Logic is in process()
|
|
447
|
+
reset() {
|
|
448
|
+
this.priceMap = null;
|
|
449
|
+
this.tickerToIdMap = null;
|
|
450
|
+
this.dependenciesLoaded = false;
|
|
451
|
+
this.pLimit = pLimit(MAX_CONCURRENT_TRANSACTIONS);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
module.exports = SocialTopicPredictivePotentialIndex;
|
|
@@ -1,91 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* --- META REFACTOR (v2) ---
|
|
6
|
-
* This calculation is now stateless. It declares its dependencies and
|
|
7
|
-
* expects them to be passed to its `process` method.
|
|
2
|
+
* @fileoverview Aggregates P&L data points for statistical analysis.
|
|
3
|
+
* This calculation is a dependency for the 'Crowd Sharpe Ratio Proxy'.
|
|
4
|
+
* It gathers the sum, sum of squares, and count of P&L for each stock.
|
|
8
5
|
*/
|
|
6
|
+
const { loadInstrumentMappings } = require('../../utils/sector_mapping_provider');
|
|
9
7
|
|
|
10
|
-
class
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
static getDependencies() {
|
|
16
|
-
return ['pnl-distribution-per-stock'];
|
|
8
|
+
class PnlDistributionPerStock {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.distributionData = {}; // { [instrumentId]: { pnl_sum: 0, pnl_sum_sq: 0, position_count: 0 } }
|
|
11
|
+
this.mappings = null;
|
|
17
12
|
}
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* @param {string} dateStr The date to run the analysis for (e.g., "2025-10-31").
|
|
24
|
-
* @param {object} dependencies The shared dependencies (db, logger).
|
|
25
|
-
* @param {object} config The computation system configuration.
|
|
26
|
-
* @param {object} fetchedDependencies In-memory results from previous passes.
|
|
27
|
-
* e.g., { 'pnl-distribution-per-stock': ... }
|
|
28
|
-
* @returns {Promise<object|null>} The analysis result or null.
|
|
29
|
-
*/
|
|
30
|
-
async process(dateStr, dependencies, config, fetchedDependencies) {
|
|
31
|
-
const { logger } = dependencies;
|
|
32
|
-
|
|
33
|
-
// 1. Get dependency from in-memory cache
|
|
34
|
-
const data = fetchedDependencies['pnl-distribution-per-stock'];
|
|
35
|
-
|
|
36
|
-
// 2. Handle missing dependency
|
|
37
|
-
if (!data) {
|
|
38
|
-
logger.log('WARN', `[CrowdSharpeRatioProxy] Missing dependency 'pnl-distribution-per-stock' for ${dateStr}. Skipping.`);
|
|
39
|
-
return null;
|
|
14
|
+
process(portfolioData, yesterdayPortfolio, userId, context) {
|
|
15
|
+
const positions = portfolioData.AggregatedPositions || portfolioData.PublicPositions;
|
|
16
|
+
if (!positions) {
|
|
17
|
+
return;
|
|
40
18
|
}
|
|
41
19
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const variance = mean_sq - (mean * mean);
|
|
62
|
-
|
|
63
|
-
if (variance <= 0) {
|
|
64
|
-
results[ticker] = {
|
|
65
|
-
average_pnl: mean,
|
|
66
|
-
std_dev_pnl: 0,
|
|
67
|
-
sharpe_ratio_proxy: 0,
|
|
68
|
-
position_count: N
|
|
69
|
-
};
|
|
70
|
-
continue;
|
|
20
|
+
for (const position of positions) {
|
|
21
|
+
const instrumentId = position.InstrumentID;
|
|
22
|
+
// Use NetProfit (which is the P&L value)
|
|
23
|
+
const netProfit = position.NetProfit;
|
|
24
|
+
|
|
25
|
+
// Ensure netProfit is a valid number
|
|
26
|
+
if (instrumentId && typeof netProfit === 'number' && isFinite(netProfit)) {
|
|
27
|
+
if (!this.distributionData[instrumentId]) {
|
|
28
|
+
this.distributionData[instrumentId] = {
|
|
29
|
+
pnl_sum: 0,
|
|
30
|
+
pnl_sum_sq: 0, // Sum of squares
|
|
31
|
+
position_count: 0
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.distributionData[instrumentId].pnl_sum += netProfit;
|
|
36
|
+
this.distributionData[instrumentId].pnl_sum_sq += (netProfit * netProfit); // Add the square
|
|
37
|
+
this.distributionData[instrumentId].position_count++;
|
|
71
38
|
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
72
41
|
|
|
73
|
-
|
|
74
|
-
|
|
42
|
+
async getResult() {
|
|
43
|
+
if (!this.mappings) {
|
|
44
|
+
this.mappings = await loadInstrumentMappings();
|
|
45
|
+
}
|
|
75
46
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
position_count: N
|
|
81
|
-
};
|
|
47
|
+
const result = {};
|
|
48
|
+
for (const instrumentId in this.distributionData) {
|
|
49
|
+
const ticker = this.mappings.instrumentToTicker[instrumentId] || instrumentId.toString();
|
|
50
|
+
result[ticker] = this.distributionData[instrumentId];
|
|
82
51
|
}
|
|
83
52
|
|
|
84
|
-
return
|
|
53
|
+
if (Object.keys(result).length === 0) return {};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
pnl_distribution_by_asset: result
|
|
57
|
+
};
|
|
85
58
|
}
|
|
86
59
|
|
|
87
|
-
|
|
88
|
-
|
|
60
|
+
reset() {
|
|
61
|
+
this.distributionData = {};
|
|
62
|
+
this.mappings = null;
|
|
63
|
+
}
|
|
89
64
|
}
|
|
90
65
|
|
|
91
|
-
module.exports =
|
|
66
|
+
module.exports = PnlDistributionPerStock;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview NEW CALCULATION (PASS 1)
|
|
3
|
+
* Aggregates all social post data for the day into a detailed matrix.
|
|
4
|
+
* For each ticker, it maps each discussed topic to its aggregated
|
|
5
|
+
* sentiment (Bullish/Bearish/Neutral) and a "Conviction Score"
|
|
6
|
+
* derived from likes and comments.
|
|
7
|
+
*
|
|
8
|
+
* This is a "socialPosts" category calculation, so it runs once per day.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
class SocialTopicSentimentMatrix {
|
|
12
|
+
constructor() {
|
|
13
|
+
// The main data structure.
|
|
14
|
+
// Format: { [ticker]: { [topic]: { ...stats } } }
|
|
15
|
+
this.matrix = {};
|
|
16
|
+
|
|
17
|
+
// Flag to ensure this runs only once per day
|
|
18
|
+
this.processed = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {null} todayPortfolio - Not used.
|
|
23
|
+
* @param {null} yesterdayPortfolio - Not used.
|
|
24
|
+
* @param {null} userId - Not used.
|
|
25
|
+
* @param {object} context - Shared context.
|
|
26
|
+
* @param {object} todayInsights - Not used.
|
|
27
|
+
* @param {object} yesterdayInsights - Not used.
|
|
28
|
+
* @param {object} todaySocialPostInsights - Map of { [postId]: postData } for today.
|
|
29
|
+
* @param {object} yesterdaySocialPostInsights - Not used.
|
|
30
|
+
*/
|
|
31
|
+
async process(todayPortfolio, yesterdayPortfolio, userId, context, todayInsights, yesterdayInsights, todaySocialPostInsights, yesterdaySocialPostInsights) {
|
|
32
|
+
|
|
33
|
+
// Run only once per day
|
|
34
|
+
if (this.processed || !todaySocialPostInsights) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.processed = true;
|
|
38
|
+
|
|
39
|
+
const posts = Object.values(todaySocialPostInsights);
|
|
40
|
+
|
|
41
|
+
for (const post of posts) {
|
|
42
|
+
if (!post || !post.tickers || !post.sentiment) continue;
|
|
43
|
+
|
|
44
|
+
const tickers = post.tickers;
|
|
45
|
+
const sentiment = post.sentiment.overallSentiment || 'Neutral';
|
|
46
|
+
|
|
47
|
+
// Use a default topic if Gemini found none
|
|
48
|
+
let topics = post.sentiment.topics || [];
|
|
49
|
+
if (topics.length === 0) {
|
|
50
|
+
topics.push('untagged');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Calculate conviction (e.g., comments are 2x as valuable as likes)
|
|
54
|
+
const convictionScore = (post.likeCount || 0) + ((post.commentCount || 0) * 2);
|
|
55
|
+
|
|
56
|
+
for (const ticker of tickers) {
|
|
57
|
+
if (!ticker) continue;
|
|
58
|
+
|
|
59
|
+
for (const topic of topics) {
|
|
60
|
+
const topicLower = topic.toLowerCase();
|
|
61
|
+
|
|
62
|
+
// Initialize ticker map
|
|
63
|
+
if (!this.matrix[ticker]) {
|
|
64
|
+
this.matrix[ticker] = {};
|
|
65
|
+
}
|
|
66
|
+
// Initialize topic map
|
|
67
|
+
if (!this.matrix[ticker][topicLower]) {
|
|
68
|
+
this.matrix[ticker][topicLower] = {
|
|
69
|
+
bullishPosts: 0,
|
|
70
|
+
bearishPosts: 0,
|
|
71
|
+
neutralPosts: 0,
|
|
72
|
+
totalPosts: 0,
|
|
73
|
+
convictionScore: 0
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Aggregate data into the bucket
|
|
78
|
+
const bucket = this.matrix[ticker][topicLower];
|
|
79
|
+
bucket.totalPosts++;
|
|
80
|
+
bucket.convictionScore += convictionScore;
|
|
81
|
+
|
|
82
|
+
if (sentiment === 'Bullish') {
|
|
83
|
+
bucket.bullishPosts++;
|
|
84
|
+
} else if (sentiment === 'Bearish') {
|
|
85
|
+
bucket.bearishPosts++;
|
|
86
|
+
} else {
|
|
87
|
+
bucket.neutralPosts++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getResult() {
|
|
95
|
+
// This entire matrix will be the result
|
|
96
|
+
return this.matrix;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
reset() {
|
|
100
|
+
this.matrix = {};
|
|
101
|
+
this.processed = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = SocialTopicSentimentMatrix;
|