hedgequantx 2.7.10 → 2.7.12
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/package.json +1 -1
- package/src/app.js +77 -11
- package/src/config/propfirms.js +16 -4
- package/src/menus/connect.js +2 -2
- package/src/pages/stats/display.js +41 -28
- package/src/pages/stats/metrics.js +11 -3
- package/src/services/rithmic/constants.js +2 -0
- package/src/services/rithmic/index.js +27 -71
- package/src/services/rithmic/orders.js +92 -45
- package/src/services/rithmic/proto/response_show_order_history_summary.proto +5 -5
- package/src/services/rithmic/trades.js +334 -0
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -20,6 +20,7 @@ const { algoTradingMenu } = require('./pages/algo');
|
|
|
20
20
|
|
|
21
21
|
// Menus
|
|
22
22
|
const { rithmicMenu, dashboardMenu, handleUpdate } = require('./menus');
|
|
23
|
+
const { PROPFIRM_CHOICES } = require('./config');
|
|
23
24
|
|
|
24
25
|
/** @type {Object|null} */
|
|
25
26
|
let currentService = null;
|
|
@@ -177,29 +178,94 @@ const run = async () => {
|
|
|
177
178
|
await banner();
|
|
178
179
|
|
|
179
180
|
if (!connections.isConnected()) {
|
|
180
|
-
// Not connected - show
|
|
181
|
+
// Not connected - show propfirm selection directly
|
|
181
182
|
const boxWidth = getLogoWidth();
|
|
182
183
|
const innerWidth = boxWidth - 2;
|
|
184
|
+
const numCols = 3;
|
|
185
|
+
|
|
186
|
+
const propfirms = PROPFIRM_CHOICES;
|
|
187
|
+
const numbered = propfirms.map((pf, i) => ({ num: i + 1, key: pf.value, name: pf.name }));
|
|
188
|
+
|
|
189
|
+
// Find max name length for alignment
|
|
190
|
+
const maxNameLen = Math.max(...numbered.map(n => n.name.length));
|
|
191
|
+
const colWidth = 4 + 1 + maxNameLen + 2; // [##] + space + name + gap
|
|
192
|
+
const totalContentWidth = numCols * colWidth;
|
|
193
|
+
const leftMargin = Math.max(2, Math.floor((innerWidth - totalContentWidth) / 2));
|
|
183
194
|
|
|
184
195
|
console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
|
|
185
|
-
console.log(chalk.cyan('║') + chalk.white.bold(centerText('
|
|
186
|
-
console.log(chalk.cyan('
|
|
187
|
-
|
|
188
|
-
|
|
196
|
+
console.log(chalk.cyan('║') + chalk.white.bold(centerText('SELECT PROPFIRM', innerWidth)) + chalk.cyan('║'));
|
|
197
|
+
console.log(chalk.cyan('║') + ' '.repeat(innerWidth) + chalk.cyan('║'));
|
|
198
|
+
|
|
199
|
+
const rows = Math.ceil(numbered.length / numCols);
|
|
200
|
+
for (let row = 0; row < rows; row++) {
|
|
201
|
+
let lineParts = [];
|
|
202
|
+
for (let col = 0; col < numCols; col++) {
|
|
203
|
+
const idx = row + col * rows;
|
|
204
|
+
if (idx < numbered.length) {
|
|
205
|
+
const item = numbered[idx];
|
|
206
|
+
const numStr = item.num.toString().padStart(2, ' ');
|
|
207
|
+
const namePadded = item.name.padEnd(maxNameLen);
|
|
208
|
+
lineParts.push({ num: `[${numStr}]`, name: namePadded });
|
|
209
|
+
} else {
|
|
210
|
+
lineParts.push(null);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let line = ' '.repeat(leftMargin);
|
|
215
|
+
for (let i = 0; i < lineParts.length; i++) {
|
|
216
|
+
if (lineParts[i]) {
|
|
217
|
+
line += chalk.cyan(lineParts[i].num) + ' ' + chalk.white(lineParts[i].name);
|
|
218
|
+
} else {
|
|
219
|
+
line += ' '.repeat(4 + 1 + maxNameLen);
|
|
220
|
+
}
|
|
221
|
+
if (i < lineParts.length - 1) line += ' ';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const lineLen = line.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
225
|
+
const rightPad = Math.max(0, innerWidth - lineLen);
|
|
226
|
+
console.log(chalk.cyan('║') + line + ' '.repeat(rightPad) + chalk.cyan('║'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(chalk.cyan('╠' + '─'.repeat(innerWidth) + '╣'));
|
|
230
|
+
console.log(chalk.cyan('║') + chalk.red(centerText('[X] Exit', innerWidth)) + chalk.cyan('║'));
|
|
189
231
|
console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
|
|
190
232
|
|
|
191
|
-
const input = await prompts.textInput(chalk.cyan('Select (
|
|
233
|
+
const input = await prompts.textInput(chalk.cyan('Select number (or X):'));
|
|
192
234
|
|
|
193
235
|
if (!input || input.toLowerCase() === 'x') {
|
|
194
236
|
console.log(chalk.gray('Goodbye!'));
|
|
195
237
|
process.exit(0);
|
|
196
238
|
}
|
|
197
239
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
const action = parseInt(input);
|
|
241
|
+
if (!isNaN(action) && action >= 1 && action <= numbered.length) {
|
|
242
|
+
const selectedPropfirm = numbered[action - 1];
|
|
243
|
+
const { loginPrompt } = require('./menus/connect');
|
|
244
|
+
const credentials = await loginPrompt(selectedPropfirm.name);
|
|
245
|
+
|
|
246
|
+
if (credentials) {
|
|
247
|
+
const spinner = ora({ text: 'Connecting to Rithmic...', color: 'yellow' }).start();
|
|
248
|
+
try {
|
|
249
|
+
const { RithmicService } = require('./services/rithmic');
|
|
250
|
+
const service = new RithmicService(selectedPropfirm.key);
|
|
251
|
+
const result = await service.login(credentials.username, credentials.password);
|
|
252
|
+
|
|
253
|
+
if (result.success) {
|
|
254
|
+
spinner.text = 'Fetching accounts...';
|
|
255
|
+
const accResult = await service.getTradingAccounts();
|
|
256
|
+
connections.add('rithmic', service, service.propfirm.name);
|
|
257
|
+
spinner.succeed(`Connected to ${service.propfirm.name} (${accResult.accounts?.length || 0} accounts)`);
|
|
258
|
+
currentService = service;
|
|
259
|
+
await refreshStats();
|
|
260
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
261
|
+
} else {
|
|
262
|
+
spinner.fail(result.error || 'Authentication failed');
|
|
263
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
spinner.fail(`Connection error: ${error.message}`);
|
|
267
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
268
|
+
}
|
|
203
269
|
}
|
|
204
270
|
}
|
|
205
271
|
} else {
|
package/src/config/propfirms.js
CHANGED
|
@@ -10,7 +10,7 @@ const PROPFIRMS = {
|
|
|
10
10
|
apex_rithmic: {
|
|
11
11
|
id: 'rithmic-apex',
|
|
12
12
|
name: 'Apex',
|
|
13
|
-
displayName: 'Apex
|
|
13
|
+
displayName: 'Apex',
|
|
14
14
|
platform: 'Rithmic',
|
|
15
15
|
rithmicSystem: 'Apex',
|
|
16
16
|
wsEndpoint: 'wss://ritpa11120.11.rithmic.com:443',
|
|
@@ -34,7 +34,7 @@ const PROPFIRMS = {
|
|
|
34
34
|
bulenox_rithmic: {
|
|
35
35
|
id: 'rithmic-bulenox',
|
|
36
36
|
name: 'Bulenox',
|
|
37
|
-
displayName: 'Bulenox
|
|
37
|
+
displayName: 'Bulenox',
|
|
38
38
|
platform: 'Rithmic',
|
|
39
39
|
rithmicSystem: 'Bulenox',
|
|
40
40
|
wsEndpoint: 'wss://ritpa11120.11.rithmic.com:443'
|
|
@@ -138,11 +138,23 @@ const PROPFIRMS = {
|
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
* PropFirm choices for menus
|
|
141
|
+
* PropFirm choices for menus (Apex first, 4PropTrader/10XFutures last, then alphabetical)
|
|
142
142
|
*/
|
|
143
143
|
const PROPFIRM_CHOICES = Object.entries(PROPFIRMS)
|
|
144
144
|
.map(([key, val]) => ({ name: val.displayName, value: key }))
|
|
145
|
-
.sort((a, b) =>
|
|
145
|
+
.sort((a, b) => {
|
|
146
|
+
// Apex always first
|
|
147
|
+
if (a.name === 'Apex') return -1;
|
|
148
|
+
if (b.name === 'Apex') return 1;
|
|
149
|
+
// 4PropTrader and 10XFutures always last
|
|
150
|
+
const lastItems = ['4PropTrader', '10XFutures'];
|
|
151
|
+
const aIsLast = lastItems.includes(a.name);
|
|
152
|
+
const bIsLast = lastItems.includes(b.name);
|
|
153
|
+
if (aIsLast && !bIsLast) return 1;
|
|
154
|
+
if (!aIsLast && bIsLast) return -1;
|
|
155
|
+
// Then alphabetical
|
|
156
|
+
return a.name.localeCompare(b.name);
|
|
157
|
+
});
|
|
146
158
|
|
|
147
159
|
/**
|
|
148
160
|
* Gets a PropFirm by key
|
package/src/menus/connect.js
CHANGED
|
@@ -70,8 +70,8 @@ const rithmicMenu = async () => {
|
|
|
70
70
|
console.log(chalk.cyan('║') + line + ' '.repeat(Math.max(0, innerWidth - lineLen)) + chalk.cyan('║'));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
console.log(chalk.cyan('
|
|
74
|
-
console.log(chalk.cyan('║') +
|
|
73
|
+
console.log(chalk.cyan('╠' + '─'.repeat(innerWidth) + '╣'));
|
|
74
|
+
console.log(chalk.cyan('║') + chalk.red(centerText('[X] Exit', innerWidth)) + chalk.cyan('║'));
|
|
75
75
|
console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
|
|
76
76
|
|
|
77
77
|
const input = await prompts.textInput(chalk.cyan('Select number (or X):'));
|
|
@@ -100,7 +100,7 @@ const renderQuantMetrics = (data) => {
|
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Render trades history section
|
|
103
|
+
* Render trades history section (round-trips)
|
|
104
104
|
*/
|
|
105
105
|
const renderTradesHistory = (data) => {
|
|
106
106
|
const { allTrades } = data;
|
|
@@ -108,60 +108,73 @@ const renderTradesHistory = (data) => {
|
|
|
108
108
|
const innerWidth = boxWidth - 2;
|
|
109
109
|
|
|
110
110
|
console.log();
|
|
111
|
-
drawBoxHeader('TRADES HISTORY', boxWidth);
|
|
111
|
+
drawBoxHeader('TRADES HISTORY (Round-Trips)', boxWidth);
|
|
112
112
|
|
|
113
113
|
const extractSymbol = (contractId) => {
|
|
114
114
|
if (!contractId) return 'N/A';
|
|
115
|
-
if (contractId.length <=
|
|
116
|
-
return contractId.substring(0,
|
|
115
|
+
if (contractId.length <= 8) return contractId;
|
|
116
|
+
return contractId.substring(0, 8);
|
|
117
117
|
};
|
|
118
118
|
|
|
119
119
|
if (allTrades.length > 0) {
|
|
120
|
-
const
|
|
121
|
-
const colSymbol = 12;
|
|
122
|
-
const colPrice = 12;
|
|
123
|
-
const colPnl = 12;
|
|
120
|
+
const colSymbol = 9;
|
|
124
121
|
const colSide = 6;
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
122
|
+
const colQty = 4;
|
|
123
|
+
const colEntry = 11;
|
|
124
|
+
const colExit = 11;
|
|
125
|
+
const colPnl = 10;
|
|
126
|
+
const separators = 18;
|
|
127
|
+
const fixedWidth = colSymbol + colSide + colQty + colEntry + colExit + colPnl + separators;
|
|
128
|
+
const colDate = Math.max(10, innerWidth - fixedWidth);
|
|
128
129
|
|
|
129
|
-
const header = ` ${'
|
|
130
|
+
const header = ` ${'Symbol'.padEnd(colSymbol)}| ${'Side'.padEnd(colSide)}| ${'Qty'.padEnd(colQty)}| ${'Entry'.padEnd(colEntry)}| ${'Exit'.padEnd(colExit)}| ${'P&L'.padEnd(colPnl)}| ${'Date'.padEnd(colDate - 2)}`;
|
|
130
131
|
console.log(chalk.cyan('\u2551') + chalk.white(header) + chalk.cyan('\u2551'));
|
|
131
132
|
console.log(chalk.cyan('\u2551') + chalk.gray('\u2500'.repeat(innerWidth)) + chalk.cyan('\u2551'));
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
// Show most recent trades first (already sorted by exitTime desc)
|
|
135
|
+
const recentTrades = allTrades.slice(0, 10);
|
|
134
136
|
|
|
135
137
|
for (const trade of recentTrades) {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const
|
|
138
|
+
const symbol = extractSymbol(trade.symbol);
|
|
139
|
+
// Round-trip: side=1 means Long (BUY then SELL), side=2 means Short (SELL then BUY)
|
|
140
|
+
const side = trade.side === 1 ? 'LONG' : trade.side === 2 ? 'SHORT' : 'N/A';
|
|
141
|
+
const qty = String(trade.quantity || 1);
|
|
142
|
+
const entry = (trade.entryPrice || 0).toFixed(2);
|
|
143
|
+
const exit = (trade.exitPrice || 0).toFixed(2);
|
|
144
|
+
const pnl = trade.pnl || trade.profitAndLoss || 0;
|
|
141
145
|
const pnlText = pnl >= 0 ? `+$${pnl.toFixed(0)}` : `-$${Math.abs(pnl).toFixed(0)}`;
|
|
142
|
-
const side = trade.side === 0 ? 'BUY' : trade.side === 1 ? 'SELL' : 'N/A';
|
|
143
|
-
const accountName = (trade.accountName || 'N/A').substring(0, colAccount - 3);
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
// Format date from exitDate (YYYYMMDD) or exitTime
|
|
148
|
+
let dateStr = '--/--';
|
|
149
|
+
if (trade.exitDate) {
|
|
150
|
+
const d = trade.exitDate;
|
|
151
|
+
dateStr = `${d.slice(4,6)}/${d.slice(6,8)}`;
|
|
152
|
+
} else if (trade.exitTime) {
|
|
153
|
+
const dt = new Date(trade.exitTime);
|
|
154
|
+
dateStr = `${(dt.getMonth()+1).toString().padStart(2,'0')}/${dt.getDate().toString().padStart(2,'0')}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
146
157
|
const symbolStr = symbol.padEnd(colSymbol);
|
|
147
|
-
const priceStr = price.padEnd(colPrice);
|
|
148
|
-
const pnlStr = pnlText.padEnd(colPnl);
|
|
149
158
|
const sideStr = side.padEnd(colSide);
|
|
150
|
-
const
|
|
159
|
+
const qtyStr = qty.padEnd(colQty);
|
|
160
|
+
const entryStr = entry.padEnd(colEntry);
|
|
161
|
+
const exitStr = exit.padEnd(colExit);
|
|
162
|
+
const pnlStr = pnlText.padEnd(colPnl);
|
|
163
|
+
const dateStrPad = dateStr.padEnd(colDate - 2);
|
|
151
164
|
|
|
152
165
|
const pnlColored = pnl >= 0 ? chalk.green(pnlStr) : chalk.red(pnlStr);
|
|
153
|
-
const sideColored = trade.side ===
|
|
166
|
+
const sideColored = trade.side === 1 ? chalk.green(sideStr) : chalk.red(sideStr);
|
|
154
167
|
|
|
155
|
-
const row = ` ${
|
|
168
|
+
const row = ` ${symbolStr}| ${sideColored}| ${qtyStr}| ${entryStr}| ${exitStr}| ${pnlColored}| ${dateStrPad}`;
|
|
156
169
|
console.log(chalk.cyan('\u2551') + row + chalk.cyan('\u2551'));
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
if (allTrades.length > 10) {
|
|
160
|
-
const moreMsg = ` ... and ${allTrades.length - 10} more
|
|
173
|
+
const moreMsg = ` ... and ${allTrades.length - 10} more round-trips`;
|
|
161
174
|
console.log(chalk.cyan('\u2551') + moreMsg.padEnd(innerWidth) + chalk.cyan('\u2551'));
|
|
162
175
|
}
|
|
163
176
|
} else {
|
|
164
|
-
const msg = ' No trades found';
|
|
177
|
+
const msg = ' No round-trip trades found';
|
|
165
178
|
console.log(chalk.cyan('\u2551') + chalk.gray(msg.padEnd(innerWidth)) + chalk.cyan('\u2551'));
|
|
166
179
|
}
|
|
167
180
|
|
|
@@ -100,17 +100,25 @@ const aggregateStats = (activeAccounts, allTrades) => {
|
|
|
100
100
|
|
|
101
101
|
for (const trade of allTrades) {
|
|
102
102
|
const pnl = trade.profitAndLoss || trade.pnl || 0;
|
|
103
|
-
const size = trade.size || trade.quantity || 1;
|
|
103
|
+
const size = trade.size || trade.fillSize || trade.quantity || 1;
|
|
104
|
+
// Rithmic: 1=BUY, 2=SELL. Other APIs: 0=BUY, 1=SELL
|
|
104
105
|
const side = trade.side;
|
|
106
|
+
const isBuy = side === 0 || side === 1; // 0 or 1 = BUY depending on API
|
|
107
|
+
const isSell = side === 2 || (side === 1 && trade.connectionType !== 'rithmic'); // 2 = SELL for Rithmic
|
|
105
108
|
|
|
106
109
|
stats.totalVolume += Math.abs(size);
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
// For Rithmic: 1=BUY (long), 2=SELL (short)
|
|
112
|
+
if (side === 1) {
|
|
109
113
|
stats.longTrades++;
|
|
110
114
|
if (pnl > 0) stats.longWins++;
|
|
111
|
-
} else if (side ===
|
|
115
|
+
} else if (side === 2) {
|
|
112
116
|
stats.shortTrades++;
|
|
113
117
|
if (pnl > 0) stats.shortWins++;
|
|
118
|
+
} else if (side === 0) {
|
|
119
|
+
// Other APIs: 0=BUY
|
|
120
|
+
stats.longTrades++;
|
|
121
|
+
if (pnl > 0) stats.longWins++;
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
if (pnl > 0) {
|
|
@@ -149,6 +149,8 @@ const PROTO_FILES = [
|
|
|
149
149
|
'response_show_order_history.proto',
|
|
150
150
|
'request_show_order_history_dates.proto',
|
|
151
151
|
'response_show_order_history_dates.proto',
|
|
152
|
+
'request_show_order_history_summary.proto',
|
|
153
|
+
'response_show_order_history_summary.proto',
|
|
152
154
|
'request_market_data_update.proto',
|
|
153
155
|
'response_market_data_update.proto',
|
|
154
156
|
'last_trade.proto',
|
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
getPositions,
|
|
18
18
|
} = require('./accounts');
|
|
19
19
|
const { placeOrder, cancelOrder, getOrders, getOrderHistory, getOrderHistoryDates, getTradeHistoryFull, closePosition } = require('./orders');
|
|
20
|
+
const { fillsToRoundTrips, calculateTradeStats } = require('./trades');
|
|
20
21
|
const { getContracts, searchContracts } = require('./contracts');
|
|
21
22
|
const { TIMEOUTS } = require('../../config/settings');
|
|
22
23
|
const { logger } = require('../../utils/logger');
|
|
@@ -242,53 +243,50 @@ class RithmicService extends EventEmitter {
|
|
|
242
243
|
async getUser() { return this.user; }
|
|
243
244
|
|
|
244
245
|
/**
|
|
245
|
-
* Get trade history from Rithmic API
|
|
246
|
+
* Get trade history from Rithmic API as round-trips
|
|
246
247
|
* @param {string} accountId - Optional account filter
|
|
247
248
|
* @param {number} days - Number of days to look back (default 30)
|
|
248
249
|
*/
|
|
249
250
|
async getTradeHistory(accountId, days = 30) {
|
|
250
|
-
// Fetch from API
|
|
251
|
+
// Fetch fills from API
|
|
251
252
|
const result = await getTradeHistoryFull(this, days);
|
|
252
253
|
|
|
253
254
|
if (!result.success) {
|
|
254
255
|
return { success: false, trades: [] };
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
let
|
|
258
|
+
let fills = result.trades || [];
|
|
258
259
|
|
|
259
260
|
// Filter by account if specified
|
|
260
261
|
if (accountId) {
|
|
261
|
-
|
|
262
|
+
fills = fills.filter(t => t.accountId === accountId);
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
...t,
|
|
267
|
-
timestamp: t.timestamp || this._parseDateTime(t.fillDate, t.fillTime),
|
|
268
|
-
}));
|
|
265
|
+
// Convert fills to round-trips with P&L
|
|
266
|
+
const roundTrips = fillsToRoundTrips(fills);
|
|
269
267
|
|
|
270
|
-
|
|
271
|
-
trades.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
272
|
-
|
|
273
|
-
return { success: true, trades };
|
|
268
|
+
return { success: true, trades: roundTrips };
|
|
274
269
|
}
|
|
275
270
|
|
|
276
271
|
/**
|
|
277
|
-
*
|
|
278
|
-
* @
|
|
272
|
+
* Get raw fills (not matched to round-trips)
|
|
273
|
+
* @param {string} accountId - Optional account filter
|
|
274
|
+
* @param {number} days - Number of days to look back (default 30)
|
|
279
275
|
*/
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
276
|
+
async getRawFills(accountId, days = 30) {
|
|
277
|
+
const result = await getTradeHistoryFull(this, days);
|
|
278
|
+
|
|
279
|
+
if (!result.success) {
|
|
280
|
+
return { success: false, fills: [] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let fills = result.trades || [];
|
|
284
|
+
|
|
285
|
+
if (accountId) {
|
|
286
|
+
fills = fills.filter(t => t.accountId === accountId);
|
|
291
287
|
}
|
|
288
|
+
|
|
289
|
+
return { success: true, fills };
|
|
292
290
|
}
|
|
293
291
|
|
|
294
292
|
/**
|
|
@@ -297,54 +295,12 @@ class RithmicService extends EventEmitter {
|
|
|
297
295
|
async getLifetimeStats(accountId) {
|
|
298
296
|
const { trades } = await this.getTradeHistory(accountId, 365);
|
|
299
297
|
|
|
300
|
-
if (trades.length === 0) {
|
|
298
|
+
if (!trades || trades.length === 0) {
|
|
301
299
|
return { success: true, stats: null };
|
|
302
300
|
}
|
|
303
301
|
|
|
304
|
-
// Calculate stats from
|
|
305
|
-
|
|
306
|
-
let winningTrades = 0;
|
|
307
|
-
let losingTrades = 0;
|
|
308
|
-
let totalProfit = 0;
|
|
309
|
-
let totalLoss = 0;
|
|
310
|
-
let longTrades = 0;
|
|
311
|
-
let shortTrades = 0;
|
|
312
|
-
let totalVolume = 0;
|
|
313
|
-
|
|
314
|
-
// Group fills by basketId to calculate P&L per trade
|
|
315
|
-
const tradeGroups = new Map();
|
|
316
|
-
for (const trade of trades) {
|
|
317
|
-
const key = trade.basketId || trade.id;
|
|
318
|
-
if (!tradeGroups.has(key)) {
|
|
319
|
-
tradeGroups.set(key, []);
|
|
320
|
-
}
|
|
321
|
-
tradeGroups.get(key).push(trade);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
for (const [, fills] of tradeGroups) {
|
|
325
|
-
const firstFill = fills[0];
|
|
326
|
-
totalVolume += fills.reduce((sum, f) => sum + f.size, 0);
|
|
327
|
-
|
|
328
|
-
if (firstFill.side === 1) longTrades++;
|
|
329
|
-
else if (firstFill.side === 2) shortTrades++;
|
|
330
|
-
|
|
331
|
-
// P&L calculation requires entry/exit matching which needs position tracking
|
|
332
|
-
// For now, count trades
|
|
333
|
-
totalTrades = tradeGroups.size;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const stats = {
|
|
337
|
-
totalTrades,
|
|
338
|
-
winningTrades,
|
|
339
|
-
losingTrades,
|
|
340
|
-
winRate: totalTrades > 0 ? ((winningTrades / totalTrades) * 100).toFixed(2) : 0,
|
|
341
|
-
totalProfit,
|
|
342
|
-
totalLoss,
|
|
343
|
-
netPnL: totalProfit - totalLoss,
|
|
344
|
-
longTrades,
|
|
345
|
-
shortTrades,
|
|
346
|
-
totalVolume,
|
|
347
|
-
};
|
|
302
|
+
// Calculate stats from round-trips
|
|
303
|
+
const stats = calculateTradeStats(trades);
|
|
348
304
|
|
|
349
305
|
return { success: true, stats };
|
|
350
306
|
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
const { REQ } = require('./constants');
|
|
7
7
|
|
|
8
|
+
// Debug mode
|
|
9
|
+
const DEBUG = process.env.HQX_DEBUG === '1';
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* Place order via ORDER_PLANT
|
|
10
13
|
* @param {RithmicService} service - The Rithmic service instance
|
|
@@ -120,6 +123,7 @@ const getOrders = async (service) => {
|
|
|
120
123
|
|
|
121
124
|
/**
|
|
122
125
|
* Get available order history dates
|
|
126
|
+
* RequestShowOrderHistoryDates (318) does NOT require account_id
|
|
123
127
|
* @param {RithmicService} service - The Rithmic service instance
|
|
124
128
|
* @returns {Promise<{success: boolean, dates: string[]}>}
|
|
125
129
|
*/
|
|
@@ -128,35 +132,53 @@ const getOrderHistoryDates = async (service) => {
|
|
|
128
132
|
return { success: false, dates: [] };
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
const { proto } = require('./protobuf');
|
|
136
|
+
|
|
131
137
|
return new Promise((resolve) => {
|
|
132
138
|
const dates = [];
|
|
133
139
|
const timeout = setTimeout(() => {
|
|
140
|
+
service.orderConn.removeListener('message', handler);
|
|
134
141
|
resolve({ success: true, dates });
|
|
135
142
|
}, 5000);
|
|
136
143
|
|
|
137
144
|
const handler = (msg) => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
// msg contains { templateId, data }
|
|
146
|
+
if (msg.templateId === 319) {
|
|
147
|
+
try {
|
|
148
|
+
const res = proto.decode('ResponseShowOrderHistoryDates', msg.data);
|
|
149
|
+
DEBUG && console.log('[OrderHistory] 319 response:', JSON.stringify(res));
|
|
150
|
+
|
|
151
|
+
// Dates come as repeated string field
|
|
152
|
+
if (res.date) {
|
|
153
|
+
const dateList = Array.isArray(res.date) ? res.date : [res.date];
|
|
154
|
+
for (const d of dateList) {
|
|
155
|
+
if (d && !dates.includes(d)) {
|
|
156
|
+
dates.push(d);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check for completion (rpCode = '0')
|
|
162
|
+
if (res.rpCode && res.rpCode.length > 0 && res.rpCode[0] === '0') {
|
|
163
|
+
clearTimeout(timeout);
|
|
164
|
+
service.orderConn.removeListener('message', handler);
|
|
165
|
+
resolve({ success: true, dates });
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
DEBUG && console.log('[OrderHistory] Error decoding 319:', e.message);
|
|
144
169
|
}
|
|
145
170
|
}
|
|
146
|
-
if (msg.templateId === 319 && msg.rpCode && msg.rpCode[0] === '0') {
|
|
147
|
-
clearTimeout(timeout);
|
|
148
|
-
service.orderConn.removeListener('message', handler);
|
|
149
|
-
resolve({ success: true, dates });
|
|
150
|
-
}
|
|
151
171
|
};
|
|
152
172
|
|
|
153
173
|
service.orderConn.on('message', handler);
|
|
154
174
|
|
|
155
175
|
try {
|
|
176
|
+
// Request 318 does NOT need account_id - just template_id and user_msg
|
|
156
177
|
service.orderConn.send('RequestShowOrderHistoryDates', {
|
|
157
178
|
templateId: REQ.SHOW_ORDER_HISTORY_DATES,
|
|
158
179
|
userMsg: ['HQX'],
|
|
159
180
|
});
|
|
181
|
+
DEBUG && console.log('[OrderHistory] Sent request 318 (ShowOrderHistoryDates)');
|
|
160
182
|
} catch (e) {
|
|
161
183
|
clearTimeout(timeout);
|
|
162
184
|
service.orderConn.removeListener('message', handler);
|
|
@@ -166,52 +188,82 @@ const getOrderHistoryDates = async (service) => {
|
|
|
166
188
|
};
|
|
167
189
|
|
|
168
190
|
/**
|
|
169
|
-
* Get order history for a specific date
|
|
191
|
+
* Get order history for a specific date using show_order_history_summary
|
|
192
|
+
* RequestShowOrderHistorySummary (324) returns ExchangeOrderNotification (352) with is_snapshot=true
|
|
170
193
|
* @param {RithmicService} service - The Rithmic service instance
|
|
171
194
|
* @param {string} date - Date in YYYYMMDD format
|
|
172
195
|
* @returns {Promise<{success: boolean, orders: Array}>}
|
|
173
196
|
*/
|
|
174
197
|
const getOrderHistory = async (service, date) => {
|
|
175
|
-
if (!service.orderConn || !service.loginInfo) {
|
|
198
|
+
if (!service.orderConn || !service.loginInfo || service.accounts.length === 0) {
|
|
176
199
|
return { success: true, orders: [] };
|
|
177
200
|
}
|
|
178
201
|
|
|
202
|
+
const { proto } = require('./protobuf');
|
|
179
203
|
const dateStr = date || new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
180
204
|
|
|
181
205
|
return new Promise((resolve) => {
|
|
182
206
|
const orders = [];
|
|
207
|
+
let receivedCount = 0;
|
|
208
|
+
const expectedAccounts = service.accounts.length;
|
|
209
|
+
const requestId = `HQX-${Date.now()}`;
|
|
210
|
+
|
|
183
211
|
const timeout = setTimeout(() => {
|
|
184
212
|
service.orderConn.removeListener('message', handler);
|
|
213
|
+
DEBUG && console.log(`[OrderHistory] Timeout. Got ${orders.length} orders`);
|
|
185
214
|
resolve({ success: true, orders });
|
|
186
215
|
}, 10000);
|
|
187
216
|
|
|
188
217
|
const handler = (msg) => {
|
|
189
|
-
//
|
|
190
|
-
if (msg.templateId === 352
|
|
218
|
+
// Response comes as template 352 (ExchangeOrderNotification) with is_snapshot=true
|
|
219
|
+
if (msg.templateId === 352) {
|
|
191
220
|
try {
|
|
192
|
-
const notification =
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
const notification = proto.decode('ExchangeOrderNotification', msg.data);
|
|
222
|
+
|
|
223
|
+
// Only process snapshot data (historical orders)
|
|
224
|
+
if (notification.isSnapshot) {
|
|
225
|
+
DEBUG && console.log('[OrderHistory] 352 snapshot:', notification.symbol, notification.notifyType);
|
|
226
|
+
|
|
227
|
+
if (notification.symbol) {
|
|
228
|
+
orders.push({
|
|
229
|
+
id: notification.fillId || notification.basketId || `${Date.now()}-${orders.length}`,
|
|
230
|
+
accountId: notification.accountId,
|
|
231
|
+
symbol: notification.symbol,
|
|
232
|
+
exchange: notification.exchange || 'CME',
|
|
233
|
+
side: notification.transactionType, // 1=BUY, 2=SELL
|
|
234
|
+
quantity: parseInt(notification.quantity) || 0,
|
|
235
|
+
price: parseFloat(notification.price) || 0,
|
|
236
|
+
fillPrice: parseFloat(notification.fillPrice) || 0,
|
|
237
|
+
fillSize: parseInt(notification.fillSize) || 0,
|
|
238
|
+
fillTime: notification.fillTime,
|
|
239
|
+
fillDate: notification.fillDate,
|
|
240
|
+
avgFillPrice: parseFloat(notification.avgFillPrice) || 0,
|
|
241
|
+
totalFillSize: parseInt(notification.totalFillSize) || 0,
|
|
242
|
+
status: notification.status,
|
|
243
|
+
notifyType: notification.notifyType,
|
|
244
|
+
isSnapshot: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
212
247
|
}
|
|
213
248
|
} catch (e) {
|
|
214
|
-
|
|
249
|
+
DEBUG && console.log('[OrderHistory] Error decoding 352:', e.message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Template 325 signals completion of order history summary
|
|
254
|
+
if (msg.templateId === 325) {
|
|
255
|
+
try {
|
|
256
|
+
const res = proto.decode('ResponseShowOrderHistorySummary', msg.data);
|
|
257
|
+
DEBUG && console.log('[OrderHistory] 325 response:', JSON.stringify(res));
|
|
258
|
+
receivedCount++;
|
|
259
|
+
|
|
260
|
+
if (receivedCount >= expectedAccounts) {
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
service.orderConn.removeListener('message', handler);
|
|
263
|
+
resolve({ success: true, orders });
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
DEBUG && console.log('[OrderHistory] Error decoding 325:', e.message);
|
|
215
267
|
}
|
|
216
268
|
}
|
|
217
269
|
};
|
|
@@ -219,23 +271,18 @@ const getOrderHistory = async (service, date) => {
|
|
|
219
271
|
service.orderConn.on('message', handler);
|
|
220
272
|
|
|
221
273
|
try {
|
|
274
|
+
// Send request 324 for each account
|
|
222
275
|
for (const acc of service.accounts) {
|
|
223
|
-
|
|
276
|
+
DEBUG && console.log(`[OrderHistory] Sending 324 for account ${acc.accountId}, date ${dateStr}`);
|
|
277
|
+
service.orderConn.send('RequestShowOrderHistorySummary', {
|
|
224
278
|
templateId: REQ.SHOW_ORDER_HISTORY,
|
|
225
|
-
userMsg: [
|
|
279
|
+
userMsg: [requestId],
|
|
226
280
|
fcmId: acc.fcmId || service.loginInfo.fcmId,
|
|
227
281
|
ibId: acc.ibId || service.loginInfo.ibId,
|
|
228
282
|
accountId: acc.accountId,
|
|
229
283
|
date: dateStr,
|
|
230
284
|
});
|
|
231
285
|
}
|
|
232
|
-
|
|
233
|
-
// Wait for responses
|
|
234
|
-
setTimeout(() => {
|
|
235
|
-
clearTimeout(timeout);
|
|
236
|
-
service.orderConn.removeListener('message', handler);
|
|
237
|
-
resolve({ success: true, orders });
|
|
238
|
-
}, 5000);
|
|
239
286
|
} catch (e) {
|
|
240
287
|
clearTimeout(timeout);
|
|
241
288
|
service.orderConn.removeListener('message', handler);
|
|
@@ -3,9 +3,9 @@ package rti;
|
|
|
3
3
|
|
|
4
4
|
message ResponseShowOrderHistorySummary
|
|
5
5
|
{
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
repeated string
|
|
10
|
-
|
|
6
|
+
// Field IDs from rundef/async_rithmic (verified Jan 2026)
|
|
7
|
+
// Same field IDs as other messages (PB_OFFSET = 100000)
|
|
8
|
+
optional int32 template_id = 154467;
|
|
9
|
+
repeated string user_msg = 132760;
|
|
10
|
+
repeated string rp_code = 132766;
|
|
11
11
|
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rithmic Trades Module
|
|
3
|
+
* Convert individual fills to round-trip trades with P&L
|
|
4
|
+
*
|
|
5
|
+
* NO MOCK DATA - Only real fills from Rithmic API
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Tick values for common futures contracts
|
|
9
|
+
const TICK_VALUES = {
|
|
10
|
+
// E-mini contracts
|
|
11
|
+
'ES': { tickSize: 0.25, tickValue: 12.50 },
|
|
12
|
+
'NQ': { tickSize: 0.25, tickValue: 5.00 },
|
|
13
|
+
'YM': { tickSize: 1.00, tickValue: 5.00 },
|
|
14
|
+
'RTY': { tickSize: 0.10, tickValue: 5.00 },
|
|
15
|
+
// Micro contracts
|
|
16
|
+
'MES': { tickSize: 0.25, tickValue: 1.25 },
|
|
17
|
+
'MNQ': { tickSize: 0.25, tickValue: 0.50 },
|
|
18
|
+
'MYM': { tickSize: 1.00, tickValue: 0.50 },
|
|
19
|
+
'M2K': { tickSize: 0.10, tickValue: 0.50 },
|
|
20
|
+
// Commodities
|
|
21
|
+
'CL': { tickSize: 0.01, tickValue: 10.00 },
|
|
22
|
+
'GC': { tickSize: 0.10, tickValue: 10.00 },
|
|
23
|
+
'SI': { tickSize: 0.005, tickValue: 25.00 },
|
|
24
|
+
'NG': { tickSize: 0.001, tickValue: 10.00 },
|
|
25
|
+
// Bonds
|
|
26
|
+
'ZB': { tickSize: 0.03125, tickValue: 31.25 },
|
|
27
|
+
'ZN': { tickSize: 0.015625, tickValue: 15.625 },
|
|
28
|
+
'ZF': { tickSize: 0.0078125, tickValue: 7.8125 },
|
|
29
|
+
// Default
|
|
30
|
+
'DEFAULT': { tickSize: 0.25, tickValue: 1.25 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get base symbol from contract (e.g., "MNQH6" -> "MNQ")
|
|
35
|
+
* @param {string} symbol - Full contract symbol
|
|
36
|
+
* @returns {string} Base symbol
|
|
37
|
+
*/
|
|
38
|
+
const getBaseSymbol = (symbol) => {
|
|
39
|
+
if (!symbol) return 'DEFAULT';
|
|
40
|
+
// Remove month/year suffix (e.g., H6, M5, Z4)
|
|
41
|
+
const match = symbol.match(/^([A-Z0-9]+?)([FGHJKMNQUVXZ]\d{1,2})?$/i);
|
|
42
|
+
return match ? match[1].toUpperCase() : symbol.toUpperCase();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get tick value for a symbol
|
|
47
|
+
* @param {string} symbol - Contract symbol
|
|
48
|
+
* @returns {Object} { tickSize, tickValue }
|
|
49
|
+
*/
|
|
50
|
+
const getTickInfo = (symbol) => {
|
|
51
|
+
const base = getBaseSymbol(symbol);
|
|
52
|
+
return TICK_VALUES[base] || TICK_VALUES['DEFAULT'];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Calculate P&L for a round-trip trade
|
|
57
|
+
* @param {number} entryPrice - Entry price
|
|
58
|
+
* @param {number} exitPrice - Exit price
|
|
59
|
+
* @param {number} quantity - Number of contracts
|
|
60
|
+
* @param {number} side - 1=Long (BUY first), 2=Short (SELL first)
|
|
61
|
+
* @param {string} symbol - Contract symbol
|
|
62
|
+
* @returns {number} P&L in dollars
|
|
63
|
+
*/
|
|
64
|
+
const calculatePnL = (entryPrice, exitPrice, quantity, side, symbol) => {
|
|
65
|
+
const { tickSize, tickValue } = getTickInfo(symbol);
|
|
66
|
+
const priceDiff = side === 1 ? (exitPrice - entryPrice) : (entryPrice - exitPrice);
|
|
67
|
+
const ticks = priceDiff / tickSize;
|
|
68
|
+
return ticks * tickValue * quantity;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert fills to round-trip trades using FIFO matching
|
|
73
|
+
* @param {Array} fills - Array of fill objects from Rithmic API
|
|
74
|
+
* @returns {Array} Array of round-trip trade objects
|
|
75
|
+
*/
|
|
76
|
+
const fillsToRoundTrips = (fills) => {
|
|
77
|
+
if (!fills || fills.length === 0) return [];
|
|
78
|
+
|
|
79
|
+
// Group fills by account and symbol
|
|
80
|
+
const groups = new Map();
|
|
81
|
+
|
|
82
|
+
for (const fill of fills) {
|
|
83
|
+
const key = `${fill.accountId}:${fill.symbol}`;
|
|
84
|
+
if (!groups.has(key)) {
|
|
85
|
+
groups.set(key, []);
|
|
86
|
+
}
|
|
87
|
+
groups.get(key).push({
|
|
88
|
+
...fill,
|
|
89
|
+
size: fill.fillSize || fill.quantity || 1,
|
|
90
|
+
price: fill.fillPrice || fill.price || 0,
|
|
91
|
+
side: fill.side || fill.transactionType, // 1=BUY, 2=SELL
|
|
92
|
+
timestamp: fill.timestamp || parseDateTime(fill.fillDate, fill.fillTime),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const roundTrips = [];
|
|
97
|
+
|
|
98
|
+
// Process each symbol group
|
|
99
|
+
for (const [key, symbolFills] of groups) {
|
|
100
|
+
// Sort by timestamp ascending (oldest first for FIFO)
|
|
101
|
+
symbolFills.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
102
|
+
|
|
103
|
+
// Position tracking: positive = long, negative = short
|
|
104
|
+
let position = 0;
|
|
105
|
+
let openTrades = []; // Stack of open fills for matching
|
|
106
|
+
|
|
107
|
+
for (const fill of symbolFills) {
|
|
108
|
+
const fillSide = fill.side; // 1=BUY, 2=SELL
|
|
109
|
+
const fillQty = fill.size;
|
|
110
|
+
const fillPrice = fill.price;
|
|
111
|
+
|
|
112
|
+
if (fillSide === 1) {
|
|
113
|
+
// BUY
|
|
114
|
+
if (position >= 0) {
|
|
115
|
+
// Opening or adding to long position
|
|
116
|
+
openTrades.push({ ...fill, remainingQty: fillQty });
|
|
117
|
+
position += fillQty;
|
|
118
|
+
} else {
|
|
119
|
+
// Closing short position (BUY to cover)
|
|
120
|
+
let qtyToClose = fillQty;
|
|
121
|
+
|
|
122
|
+
while (qtyToClose > 0 && openTrades.length > 0) {
|
|
123
|
+
const openTrade = openTrades[0];
|
|
124
|
+
const closeQty = Math.min(qtyToClose, openTrade.remainingQty);
|
|
125
|
+
|
|
126
|
+
// Create round-trip (short trade)
|
|
127
|
+
const pnl = calculatePnL(openTrade.price, fillPrice, closeQty, 2, fill.symbol);
|
|
128
|
+
roundTrips.push({
|
|
129
|
+
id: `${openTrade.id || openTrade.fillId}-${fill.id || fill.fillId}`,
|
|
130
|
+
accountId: fill.accountId,
|
|
131
|
+
symbol: fill.symbol,
|
|
132
|
+
exchange: fill.exchange,
|
|
133
|
+
side: 2, // Short
|
|
134
|
+
quantity: closeQty,
|
|
135
|
+
entryPrice: openTrade.price,
|
|
136
|
+
exitPrice: fillPrice,
|
|
137
|
+
entryTime: openTrade.timestamp,
|
|
138
|
+
exitTime: fill.timestamp,
|
|
139
|
+
entryDate: openTrade.fillDate,
|
|
140
|
+
exitDate: fill.fillDate,
|
|
141
|
+
pnl: pnl,
|
|
142
|
+
profitAndLoss: pnl,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
openTrade.remainingQty -= closeQty;
|
|
146
|
+
qtyToClose -= closeQty;
|
|
147
|
+
position += closeQty;
|
|
148
|
+
|
|
149
|
+
if (openTrade.remainingQty === 0) {
|
|
150
|
+
openTrades.shift();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// If still have qty, it's opening a new long
|
|
155
|
+
if (qtyToClose > 0) {
|
|
156
|
+
openTrades.push({ ...fill, remainingQty: qtyToClose });
|
|
157
|
+
position += qtyToClose;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (fillSide === 2) {
|
|
161
|
+
// SELL
|
|
162
|
+
if (position <= 0) {
|
|
163
|
+
// Opening or adding to short position
|
|
164
|
+
openTrades.push({ ...fill, remainingQty: fillQty });
|
|
165
|
+
position -= fillQty;
|
|
166
|
+
} else {
|
|
167
|
+
// Closing long position (SELL to close)
|
|
168
|
+
let qtyToClose = fillQty;
|
|
169
|
+
|
|
170
|
+
while (qtyToClose > 0 && openTrades.length > 0) {
|
|
171
|
+
const openTrade = openTrades[0];
|
|
172
|
+
const closeQty = Math.min(qtyToClose, openTrade.remainingQty);
|
|
173
|
+
|
|
174
|
+
// Create round-trip (long trade)
|
|
175
|
+
const pnl = calculatePnL(openTrade.price, fillPrice, closeQty, 1, fill.symbol);
|
|
176
|
+
roundTrips.push({
|
|
177
|
+
id: `${openTrade.id || openTrade.fillId}-${fill.id || fill.fillId}`,
|
|
178
|
+
accountId: fill.accountId,
|
|
179
|
+
symbol: fill.symbol,
|
|
180
|
+
exchange: fill.exchange,
|
|
181
|
+
side: 1, // Long
|
|
182
|
+
quantity: closeQty,
|
|
183
|
+
entryPrice: openTrade.price,
|
|
184
|
+
exitPrice: fillPrice,
|
|
185
|
+
entryTime: openTrade.timestamp,
|
|
186
|
+
exitTime: fill.timestamp,
|
|
187
|
+
entryDate: openTrade.fillDate,
|
|
188
|
+
exitDate: fill.fillDate,
|
|
189
|
+
pnl: pnl,
|
|
190
|
+
profitAndLoss: pnl,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
openTrade.remainingQty -= closeQty;
|
|
194
|
+
qtyToClose -= closeQty;
|
|
195
|
+
position -= closeQty;
|
|
196
|
+
|
|
197
|
+
if (openTrade.remainingQty === 0) {
|
|
198
|
+
openTrades.shift();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// If still have qty, it's opening a new short
|
|
203
|
+
if (qtyToClose > 0) {
|
|
204
|
+
openTrades.push({ ...fill, remainingQty: qtyToClose });
|
|
205
|
+
position -= qtyToClose;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Sort round-trips by exit time descending (newest first)
|
|
213
|
+
roundTrips.sort((a, b) => (b.exitTime || 0) - (a.exitTime || 0));
|
|
214
|
+
|
|
215
|
+
return roundTrips;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse Rithmic date/time to timestamp
|
|
220
|
+
* @param {string} dateStr - Date in YYYYMMDD format
|
|
221
|
+
* @param {string} timeStr - Time in HH:MM:SS format
|
|
222
|
+
* @returns {number} Unix timestamp in milliseconds
|
|
223
|
+
*/
|
|
224
|
+
const parseDateTime = (dateStr, timeStr) => {
|
|
225
|
+
if (!dateStr) return Date.now();
|
|
226
|
+
try {
|
|
227
|
+
const year = dateStr.slice(0, 4);
|
|
228
|
+
const month = dateStr.slice(4, 6);
|
|
229
|
+
const day = dateStr.slice(6, 8);
|
|
230
|
+
const time = timeStr || '00:00:00';
|
|
231
|
+
return new Date(`${year}-${month}-${day}T${time}Z`).getTime();
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return Date.now();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Calculate summary statistics from round-trips
|
|
239
|
+
* @param {Array} roundTrips - Array of round-trip trades
|
|
240
|
+
* @returns {Object} Summary statistics
|
|
241
|
+
*/
|
|
242
|
+
const calculateTradeStats = (roundTrips) => {
|
|
243
|
+
if (!roundTrips || roundTrips.length === 0) {
|
|
244
|
+
return {
|
|
245
|
+
totalTrades: 0,
|
|
246
|
+
winningTrades: 0,
|
|
247
|
+
losingTrades: 0,
|
|
248
|
+
breakEvenTrades: 0,
|
|
249
|
+
totalPnL: 0,
|
|
250
|
+
totalProfit: 0,
|
|
251
|
+
totalLoss: 0,
|
|
252
|
+
winRate: 0,
|
|
253
|
+
avgWin: 0,
|
|
254
|
+
avgLoss: 0,
|
|
255
|
+
largestWin: 0,
|
|
256
|
+
largestLoss: 0,
|
|
257
|
+
profitFactor: 0,
|
|
258
|
+
longTrades: 0,
|
|
259
|
+
shortTrades: 0,
|
|
260
|
+
longWins: 0,
|
|
261
|
+
shortWins: 0,
|
|
262
|
+
totalVolume: 0,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let stats = {
|
|
267
|
+
totalTrades: roundTrips.length,
|
|
268
|
+
winningTrades: 0,
|
|
269
|
+
losingTrades: 0,
|
|
270
|
+
breakEvenTrades: 0,
|
|
271
|
+
totalPnL: 0,
|
|
272
|
+
totalProfit: 0,
|
|
273
|
+
totalLoss: 0,
|
|
274
|
+
largestWin: 0,
|
|
275
|
+
largestLoss: 0,
|
|
276
|
+
longTrades: 0,
|
|
277
|
+
shortTrades: 0,
|
|
278
|
+
longWins: 0,
|
|
279
|
+
shortWins: 0,
|
|
280
|
+
totalVolume: 0,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
for (const trade of roundTrips) {
|
|
284
|
+
const pnl = trade.pnl || 0;
|
|
285
|
+
stats.totalPnL += pnl;
|
|
286
|
+
stats.totalVolume += trade.quantity || 1;
|
|
287
|
+
|
|
288
|
+
if (trade.side === 1) {
|
|
289
|
+
stats.longTrades++;
|
|
290
|
+
if (pnl > 0) stats.longWins++;
|
|
291
|
+
} else {
|
|
292
|
+
stats.shortTrades++;
|
|
293
|
+
if (pnl > 0) stats.shortWins++;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (pnl > 0) {
|
|
297
|
+
stats.winningTrades++;
|
|
298
|
+
stats.totalProfit += pnl;
|
|
299
|
+
if (pnl > stats.largestWin) stats.largestWin = pnl;
|
|
300
|
+
} else if (pnl < 0) {
|
|
301
|
+
stats.losingTrades++;
|
|
302
|
+
stats.totalLoss += Math.abs(pnl);
|
|
303
|
+
if (pnl < stats.largestLoss) stats.largestLoss = pnl;
|
|
304
|
+
} else {
|
|
305
|
+
stats.breakEvenTrades++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Calculate derived metrics
|
|
310
|
+
stats.winRate = stats.totalTrades > 0
|
|
311
|
+
? (stats.winningTrades / stats.totalTrades) * 100
|
|
312
|
+
: 0;
|
|
313
|
+
stats.avgWin = stats.winningTrades > 0
|
|
314
|
+
? stats.totalProfit / stats.winningTrades
|
|
315
|
+
: 0;
|
|
316
|
+
stats.avgLoss = stats.losingTrades > 0
|
|
317
|
+
? stats.totalLoss / stats.losingTrades
|
|
318
|
+
: 0;
|
|
319
|
+
stats.profitFactor = stats.totalLoss > 0
|
|
320
|
+
? stats.totalProfit / stats.totalLoss
|
|
321
|
+
: (stats.totalProfit > 0 ? Infinity : 0);
|
|
322
|
+
|
|
323
|
+
return stats;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
fillsToRoundTrips,
|
|
328
|
+
calculateTradeStats,
|
|
329
|
+
calculatePnL,
|
|
330
|
+
getTickInfo,
|
|
331
|
+
getBaseSymbol,
|
|
332
|
+
parseDateTime,
|
|
333
|
+
TICK_VALUES,
|
|
334
|
+
};
|