@gaberoo/kalshitools 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ import { Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { calculateAveragePnL, calculateExposureRatio, calculatePortfolioConcentration, calculateTotalFees, calculateWinRate, } from '../../lib/analytics.js';
4
+ import { BaseCommand } from '../../lib/base-command.js';
5
+ import { createClientFromConfig } from '../../lib/kalshi/index.js';
6
+ import { logger } from '../../lib/logger.js';
7
+ export default class PortfolioAnalytics extends BaseCommand {
8
+ static description = 'Comprehensive portfolio performance analytics';
9
+ static examples = [
10
+ '<%= config.bin %> <%= command.id %>',
11
+ '<%= config.bin %> <%= command.id %> --period 7',
12
+ '<%= config.bin %> <%= command.id %> --min-ts 2026-01-01T00:00:00Z',
13
+ '<%= config.bin %> <%= command.id %> --json',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ 'max-ts': Flags.string({
18
+ description: 'End date for analysis (ISO-8601 format)',
19
+ }),
20
+ 'min-ts': Flags.string({
21
+ description: 'Start date for analysis (ISO-8601 format)',
22
+ }),
23
+ 'period': Flags.integer({
24
+ description: 'Analysis period in days (overridden by min-ts/max-ts)',
25
+ }),
26
+ };
27
+ async run() {
28
+ const { flags } = await this.parse(PortfolioAnalytics);
29
+ try {
30
+ const client = createClientFromConfig();
31
+ // Calculate time range
32
+ const now = new Date();
33
+ let minTs;
34
+ let maxTs;
35
+ let periodDays = flags.period || 30;
36
+ if (flags['min-ts']) {
37
+ minTs = Math.floor(new Date(flags['min-ts']).getTime() / 1000);
38
+ }
39
+ else if (flags.period) {
40
+ minTs = Math.floor((now.getTime() - flags.period * 24 * 60 * 60 * 1000) / 1000);
41
+ }
42
+ else {
43
+ // Default to 30 days
44
+ minTs = Math.floor((now.getTime() - 30 * 24 * 60 * 60 * 1000) / 1000);
45
+ }
46
+ if (flags['max-ts']) {
47
+ maxTs = Math.floor(new Date(flags['max-ts']).getTime() / 1000);
48
+ }
49
+ // Calculate actual period if we have both timestamps
50
+ if (minTs && maxTs) {
51
+ periodDays = Math.floor((maxTs - minTs) / (24 * 60 * 60));
52
+ }
53
+ else if (minTs) {
54
+ periodDays = Math.floor((now.getTime() / 1000 - minTs) / (24 * 60 * 60));
55
+ }
56
+ // Fetch data from API
57
+ const [balance, positions, fillsResult] = await Promise.all([
58
+ client.getBalance(),
59
+ client.getPositions({ settlement_status: 'open' }),
60
+ client.getFills({ limit: 500, max_ts: maxTs, min_ts: minTs }),
61
+ ]);
62
+ const { fills } = fillsResult;
63
+ // Calculate metrics
64
+ const winRate = calculateWinRate(fills);
65
+ const { avgLoss, avgWin } = calculateAveragePnL(fills);
66
+ const totalFees = calculateTotalFees(fills);
67
+ // Portfolio metrics
68
+ const portfolioValue = balance.portfolio_value / 100; // Convert from cents
69
+ const cashBalance = balance.balance / 100;
70
+ const totalCost = positions.reduce((sum, pos) => sum + pos.total_cost, 0);
71
+ const totalExposure = positions.reduce((sum, pos) => sum + pos.market_exposure, 0);
72
+ const realizedPnL = positions.reduce((sum, pos) => sum + pos.realized_pnl, 0);
73
+ const unrealizedPnL = totalExposure - totalCost;
74
+ const exposureRatio = calculateExposureRatio(positions, portfolioValue);
75
+ const concentration = calculatePortfolioConcentration(positions, portfolioValue);
76
+ // Calculate total return
77
+ const initialCapital = portfolioValue - realizedPnL - unrealizedPnL;
78
+ const totalReturnPct = initialCapital > 0 ? ((portfolioValue - initialCapital) / initialCapital) * 100 : 0;
79
+ // Trade statistics
80
+ const totalTrades = fills.length;
81
+ const winningTrades = Math.round(totalTrades * winRate);
82
+ const losingTrades = totalTrades - winningTrades;
83
+ // Concentration by ticker
84
+ const concentrationByTicker = {};
85
+ for (const [ticker, pct] of concentration) {
86
+ concentrationByTicker[ticker] = Math.round(pct * 100) / 100;
87
+ }
88
+ // Find largest position
89
+ const largestPositionPct = concentration.size > 0
90
+ ? Math.max(...concentration.values())
91
+ : 0;
92
+ // Fee impact
93
+ const feeImpactPct = portfolioValue > 0 ? (totalFees / portfolioValue) * 100 : 0;
94
+ if (this.formatter.isJSONMode()) {
95
+ this.formatter.success({
96
+ fees: {
97
+ fee_impact_pct: -Math.abs(feeImpactPct),
98
+ total_fees: totalFees,
99
+ },
100
+ period: {
101
+ days: periodDays,
102
+ end: maxTs ? new Date(maxTs * 1000).toISOString() : now.toISOString(),
103
+ start: new Date(minTs * 1000).toISOString(),
104
+ },
105
+ pnl: {
106
+ avg_loss: avgLoss,
107
+ avg_win: avgWin,
108
+ realized_pnl: realizedPnL,
109
+ total_pnl: realizedPnL + unrealizedPnL,
110
+ total_return_pct: totalReturnPct,
111
+ unrealized_pnl: unrealizedPnL,
112
+ },
113
+ portfolio: {
114
+ cash_balance: cashBalance,
115
+ current_value: portfolioValue,
116
+ exposure: totalExposure,
117
+ exposure_ratio: exposureRatio,
118
+ total_cost: totalCost,
119
+ },
120
+ risk: {
121
+ concentration_by_ticker: concentrationByTicker,
122
+ largest_position_pct: largestPositionPct,
123
+ },
124
+ trades: {
125
+ losing: losingTrades,
126
+ total: totalTrades,
127
+ win_rate: winRate,
128
+ winning: winningTrades,
129
+ },
130
+ });
131
+ }
132
+ else {
133
+ // Human-readable output
134
+ this.log(chalk.cyan.bold('Portfolio Analytics'));
135
+ this.log();
136
+ // Period
137
+ this.log(chalk.yellow('Period:'));
138
+ this.log(` ${periodDays} days (${new Date(minTs * 1000).toLocaleDateString()} - ${maxTs ? new Date(maxTs * 1000).toLocaleDateString() : 'now'})`);
139
+ this.log();
140
+ // Trading Performance
141
+ this.log(chalk.yellow('Trading Performance:'));
142
+ this.log(` Total Trades: ${totalTrades}`);
143
+ this.log(` Win Rate: ${chalk.green((winRate * 100).toFixed(1) + '%')} (${winningTrades}W / ${losingTrades}L)`);
144
+ this.log(` Avg Win: ${chalk.green('+$' + avgWin.toFixed(2))}`);
145
+ this.log(` Avg Loss: ${chalk.red('$' + avgLoss.toFixed(2))}`);
146
+ this.log();
147
+ // P&L
148
+ this.log(chalk.yellow('Profit & Loss:'));
149
+ const totalPnL = realizedPnL + unrealizedPnL;
150
+ this.log(` Total P&L: ${totalPnL >= 0 ? chalk.green('+$' + totalPnL.toFixed(2)) : chalk.red('-$' + Math.abs(totalPnL).toFixed(2))}`);
151
+ this.log(` Realized: ${realizedPnL >= 0 ? chalk.green('+$' + realizedPnL.toFixed(2)) : chalk.red('-$' + Math.abs(realizedPnL).toFixed(2))}`);
152
+ this.log(` Unrealized: ${unrealizedPnL >= 0 ? chalk.green('+$' + unrealizedPnL.toFixed(2)) : chalk.red('-$' + Math.abs(unrealizedPnL).toFixed(2))}`);
153
+ this.log(` Total Return: ${totalReturnPct >= 0 ? chalk.green('+' + totalReturnPct.toFixed(2) + '%') : chalk.red(totalReturnPct.toFixed(2) + '%')}`);
154
+ this.log();
155
+ // Portfolio
156
+ this.log(chalk.yellow('Portfolio:'));
157
+ this.log(` Current Value: ${chalk.cyan('$' + portfolioValue.toFixed(2))}`);
158
+ this.log(` Cash Balance: ${chalk.cyan('$' + cashBalance.toFixed(2))}`);
159
+ this.log(` Total Exposure: ${chalk.cyan('$' + totalExposure.toFixed(2))}`);
160
+ this.log(` Exposure Ratio: ${chalk.cyan((exposureRatio * 100).toFixed(1) + '%')}`);
161
+ this.log();
162
+ // Risk
163
+ this.log(chalk.yellow('Risk:'));
164
+ this.log(` Largest Position: ${largestPositionPct >= 20 ? chalk.red(largestPositionPct.toFixed(1) + '%') : chalk.green(largestPositionPct.toFixed(1) + '%')}`);
165
+ if (concentration.size > 0) {
166
+ this.log(` Top Concentrations:`);
167
+ const sorted = [...concentration.entries()]
168
+ .sort((a, b) => b[1] - a[1])
169
+ .slice(0, 5);
170
+ for (const [ticker, pct] of sorted) {
171
+ this.log(` ${ticker}: ${pct >= 20 ? chalk.red(pct.toFixed(1) + '%') : chalk.cyan(pct.toFixed(1) + '%')}`);
172
+ }
173
+ }
174
+ this.log();
175
+ // Fees
176
+ this.log(chalk.yellow('Fees:'));
177
+ this.log(` Total Fees: ${chalk.red('$' + totalFees.toFixed(2))}`);
178
+ this.log(` Fee Impact: ${chalk.red(feeImpactPct.toFixed(2) + '%')}`);
179
+ }
180
+ logger.info({
181
+ exposure_ratio: exposureRatio,
182
+ period_days: periodDays,
183
+ total_pnl: realizedPnL + unrealizedPnL,
184
+ total_trades: totalTrades,
185
+ win_rate: winRate,
186
+ }, 'Portfolio analytics generated');
187
+ }
188
+ catch (error) {
189
+ throw error;
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../lib/base-command.js';
2
+ export default class PortfolioHistory extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'group-by': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'max-ts': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ 'min-ts': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ ticker: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,245 @@
1
+ import { Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { calculateMakerTakerStats, groupFillsByPeriod, groupFillsByTicker, } from '../../lib/analytics.js';
4
+ import { BaseCommand } from '../../lib/base-command.js';
5
+ import { createClientFromConfig } from '../../lib/kalshi/index.js';
6
+ import { logger } from '../../lib/logger.js';
7
+ export default class PortfolioHistory extends BaseCommand {
8
+ static description = 'Historical performance analysis with time-series data';
9
+ static examples = [
10
+ '<%= config.bin %> <%= command.id %>',
11
+ '<%= config.bin %> <%= command.id %> --ticker MARKET-A',
12
+ '<%= config.bin %> <%= command.id %> --group-by week',
13
+ '<%= config.bin %> <%= command.id %> --min-ts 2026-01-01T00:00:00Z --max-ts 2026-02-01T00:00:00Z',
14
+ '<%= config.bin %> <%= command.id %> --json',
15
+ ];
16
+ static flags = {
17
+ ...BaseCommand.baseFlags,
18
+ 'group-by': Flags.string({
19
+ default: 'day',
20
+ description: 'Time aggregation level',
21
+ options: ['day', 'week', 'month'],
22
+ }),
23
+ 'limit': Flags.integer({
24
+ default: 500,
25
+ description: 'Maximum fills to fetch (handles pagination)',
26
+ }),
27
+ 'max-ts': Flags.string({
28
+ description: 'End date (ISO-8601 format)',
29
+ }),
30
+ 'min-ts': Flags.string({
31
+ description: 'Start date (ISO-8601 format)',
32
+ }),
33
+ 'ticker': Flags.string({
34
+ description: 'Filter by specific market ticker',
35
+ }),
36
+ };
37
+ async run() {
38
+ const { flags } = await this.parse(PortfolioHistory);
39
+ try {
40
+ const client = createClientFromConfig();
41
+ // Convert timestamps
42
+ const minTs = flags['min-ts'] ? Math.floor(new Date(flags['min-ts']).getTime() / 1000) : undefined;
43
+ const maxTs = flags['max-ts'] ? Math.floor(new Date(flags['max-ts']).getTime() / 1000) : undefined;
44
+ // Fetch fills with pagination
45
+ const allFills = [];
46
+ let cursor;
47
+ let remaining = flags.limit;
48
+ while (remaining > 0) {
49
+ const batchSize = Math.min(remaining, 100); // API limit per request
50
+ const result = await client.getFills({
51
+ cursor,
52
+ limit: batchSize,
53
+ max_ts: maxTs,
54
+ min_ts: minTs,
55
+ ticker: flags.ticker,
56
+ });
57
+ allFills.push(...result.fills);
58
+ remaining -= result.fills.length;
59
+ if (!result.cursor || result.fills.length === 0) {
60
+ break;
61
+ }
62
+ cursor = result.cursor;
63
+ }
64
+ if (allFills.length === 0) {
65
+ if (this.formatter.isJSONMode()) {
66
+ this.formatter.success({
67
+ by_ticker: {},
68
+ maker_taker: {
69
+ maker: { fills: 0, volume: 0 },
70
+ taker: { fills: 0, volume: 0 },
71
+ },
72
+ period: {
73
+ days: 0,
74
+ end: maxTs ? new Date(maxTs * 1000).toISOString() : new Date().toISOString(),
75
+ start: minTs ? new Date(minTs * 1000).toISOString() : new Date().toISOString(),
76
+ },
77
+ summary: {
78
+ buy_volume: 0,
79
+ maker_fills: 0,
80
+ net_volume: 0,
81
+ sell_volume: 0,
82
+ taker_fills: 0,
83
+ total_fees: 0,
84
+ total_fills: 0,
85
+ total_volume: 0,
86
+ },
87
+ time_series: {},
88
+ top_trades: {},
89
+ });
90
+ }
91
+ else {
92
+ this.log(chalk.yellow('No fills found for the specified period'));
93
+ }
94
+ return;
95
+ }
96
+ // Calculate period
97
+ const oldestFill = allFills[allFills.length - 1];
98
+ const startDate = minTs ? new Date(minTs * 1000) : new Date(oldestFill.created_time);
99
+ const endDate = maxTs ? new Date(maxTs * 1000) : new Date(allFills[0].created_time);
100
+ const periodDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000));
101
+ // Calculate summary statistics
102
+ const totalVolume = allFills.reduce((sum, f) => sum + f.count * f.price, 0);
103
+ const buyVolume = allFills.filter(f => f.action === 'buy').reduce((sum, f) => sum + f.count * f.price, 0);
104
+ const sellVolume = allFills.filter(f => f.action === 'sell').reduce((sum, f) => sum + f.count * f.price, 0);
105
+ const makerFills = allFills.filter(f => !f.is_taker).length;
106
+ const takerFills = allFills.filter(f => f.is_taker).length;
107
+ // Estimate fees
108
+ const MAKER_FEE = 0.005;
109
+ const TAKER_FEE = 0.015;
110
+ const totalFees = allFills.reduce((sum, f) => {
111
+ const notional = f.count * f.price;
112
+ return sum + notional * (f.is_taker ? TAKER_FEE : MAKER_FEE);
113
+ }, 0);
114
+ // Group by ticker
115
+ const byTicker = groupFillsByTicker(allFills);
116
+ const byTickerArray = [...byTicker.entries()].map(([ticker, stats]) => ({
117
+ fills: stats.count,
118
+ pnl: stats.estimatedPnL,
119
+ ticker,
120
+ volume: stats.volume,
121
+ win_rate: 0, // TODO: Calculate from individual trades
122
+ }));
123
+ // Group by time period
124
+ const timeSeries = groupFillsByPeriod(allFills, flags['group-by']);
125
+ const timeSeriesArray = [...timeSeries.entries()]
126
+ .map(([date, fills]) => ({
127
+ date,
128
+ fills: fills.length,
129
+ pnl: 0, // TODO: Calculate P&L for period
130
+ volume: fills.reduce((sum, f) => sum + f.count * f.price, 0),
131
+ }))
132
+ .sort((a, b) => a.date.localeCompare(b.date));
133
+ // Maker vs taker analysis
134
+ const makerTakerStats = calculateMakerTakerStats(allFills);
135
+ // Find top trades (by volume)
136
+ const sortedByVolume = [...allFills].sort((a, b) => b.count * b.price - a.count * a.price);
137
+ const largestTrade = sortedByVolume[0];
138
+ if (this.formatter.isJSONMode()) {
139
+ this.formatter.success({
140
+ by_ticker: Object.fromEntries(byTickerArray.map(t => [t.ticker, {
141
+ fills: t.fills,
142
+ pnl: t.pnl,
143
+ volume: t.volume,
144
+ win_rate: t.win_rate,
145
+ }])),
146
+ maker_taker: makerTakerStats,
147
+ period: {
148
+ days: periodDays,
149
+ end: endDate.toISOString(),
150
+ start: startDate.toISOString(),
151
+ },
152
+ summary: {
153
+ buy_volume: buyVolume,
154
+ maker_fills: makerFills,
155
+ net_volume: sellVolume - buyVolume,
156
+ sell_volume: sellVolume,
157
+ taker_fills: takerFills,
158
+ total_fees: totalFees,
159
+ total_fills: allFills.length,
160
+ total_volume: totalVolume,
161
+ },
162
+ time_series: {
163
+ [flags['group-by']]: timeSeriesArray,
164
+ },
165
+ top_trades: {
166
+ largest_by_volume: largestTrade ? {
167
+ date: largestTrade.created_time,
168
+ ticker: largestTrade.ticker,
169
+ volume: largestTrade.count * largestTrade.price,
170
+ } : null,
171
+ },
172
+ });
173
+ }
174
+ else {
175
+ // Human-readable output
176
+ this.log(chalk.cyan.bold('Historical Performance Analysis'));
177
+ this.log();
178
+ // Period
179
+ this.log(chalk.yellow('Period:'));
180
+ this.log(` ${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()} (${periodDays} days)`);
181
+ this.log();
182
+ // Summary
183
+ this.log(chalk.yellow('Summary:'));
184
+ this.log(` Total Fills: ${allFills.length}`);
185
+ this.log(` Total Volume: ${chalk.cyan('$' + totalVolume.toFixed(2))}`);
186
+ this.log(` Buy Volume: ${chalk.green('$' + buyVolume.toFixed(2))}`);
187
+ this.log(` Sell Volume: ${chalk.red('$' + sellVolume.toFixed(2))}`);
188
+ this.log(` Net Volume: ${sellVolume - buyVolume >= 0 ? chalk.green('+$' + (sellVolume - buyVolume).toFixed(2)) : chalk.red('-$' + Math.abs(sellVolume - buyVolume).toFixed(2))}`);
189
+ this.log(` Total Fees: ${chalk.red('$' + totalFees.toFixed(2))}`);
190
+ this.log();
191
+ // Maker vs Taker
192
+ this.log(chalk.yellow('Maker vs Taker:'));
193
+ this.log(` Maker Fills: ${makerFills} (${((makerFills / allFills.length) * 100).toFixed(1)}%)`);
194
+ this.log(` Maker Volume: ${chalk.cyan('$' + makerTakerStats.maker.volume.toFixed(2))}`);
195
+ this.log(` Taker Fills: ${takerFills} (${((takerFills / allFills.length) * 100).toFixed(1)}%)`);
196
+ this.log(` Taker Volume: ${chalk.cyan('$' + makerTakerStats.taker.volume.toFixed(2))}`);
197
+ this.log();
198
+ // By Ticker
199
+ if (byTickerArray.length > 0) {
200
+ this.log(chalk.yellow('Performance by Ticker:'));
201
+ const topTickers = byTickerArray
202
+ .sort((a, b) => b.volume - a.volume)
203
+ .slice(0, 10);
204
+ const tickerRows = topTickers.map(t => [
205
+ t.ticker,
206
+ t.fills.toString(),
207
+ '$' + t.volume.toFixed(2),
208
+ t.pnl >= 0 ? chalk.green('+$' + t.pnl.toFixed(2)) : chalk.red('$' + t.pnl.toFixed(2)),
209
+ ]);
210
+ this.formatter.outputTable(['Ticker', 'Fills', 'Volume', 'Est. P&L'], tickerRows);
211
+ this.log();
212
+ }
213
+ // Time Series
214
+ this.log(chalk.yellow(`Time Series (${flags['group-by']}ly):`));
215
+ if (timeSeriesArray.length > 0) {
216
+ const recent = timeSeriesArray.slice(-10); // Show last 10 periods
217
+ const timeRows = recent.map(t => [
218
+ t.date,
219
+ t.fills.toString(),
220
+ '$' + t.volume.toFixed(2),
221
+ ]);
222
+ this.formatter.outputTable(['Period', 'Fills', 'Volume'], timeRows);
223
+ if (timeSeriesArray.length > 10) {
224
+ this.log();
225
+ this.log(chalk.gray(` ... showing last 10 of ${timeSeriesArray.length} periods`));
226
+ }
227
+ }
228
+ this.log();
229
+ // Top Trades
230
+ if (largestTrade) {
231
+ this.log(chalk.yellow('Top Trades:'));
232
+ this.log(` Largest by Volume: ${chalk.cyan(largestTrade.ticker)} - $${(largestTrade.count * largestTrade.price).toFixed(2)} (${new Date(largestTrade.created_time).toLocaleDateString()})`);
233
+ }
234
+ }
235
+ logger.info({
236
+ fills: allFills.length,
237
+ period_days: periodDays,
238
+ total_volume: totalVolume,
239
+ }, 'Historical analysis completed');
240
+ }
241
+ catch (error) {
242
+ throw error;
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,11 @@
1
+ import { BaseCommand } from '../../lib/base-command.js';
2
+ export default class PortfolioRisk extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'group-by': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ threshold: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,206 @@
1
+ import { Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { BaseCommand } from '../../lib/base-command.js';
4
+ import { createClientFromConfig } from '../../lib/kalshi/index.js';
5
+ import { logger } from '../../lib/logger.js';
6
+ import { calculateConcentrationByGroup, calculatePortfolioRiskScore, detectCorrelatedPositions, identifyHighRiskPositions, suggestMaxPositionSize, } from '../../lib/risk.js';
7
+ export default class PortfolioRisk extends BaseCommand {
8
+ static description = 'Real-time portfolio risk analysis and warnings';
9
+ static examples = [
10
+ '<%= config.bin %> <%= command.id %>',
11
+ '<%= config.bin %> <%= command.id %> --threshold 15',
12
+ '<%= config.bin %> <%= command.id %> --group-by event',
13
+ '<%= config.bin %> <%= command.id %> --json',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ 'group-by': Flags.string({
18
+ default: 'ticker',
19
+ description: 'Grouping for concentration analysis',
20
+ options: ['event', 'series', 'ticker'],
21
+ }),
22
+ 'threshold': Flags.integer({
23
+ default: 20,
24
+ description: 'Alert threshold for position concentration (%)',
25
+ }),
26
+ };
27
+ async run() {
28
+ const { flags } = await this.parse(PortfolioRisk);
29
+ try {
30
+ const client = createClientFromConfig();
31
+ // Fetch data
32
+ const [balance, positions] = await Promise.all([
33
+ client.getBalance(),
34
+ client.getPositions({ settlement_status: 'open' }),
35
+ ]);
36
+ if (positions.length === 0) {
37
+ if (this.formatter.isJSONMode()) {
38
+ this.formatter.success({
39
+ alerts: [],
40
+ concentrations: { by_event: [], by_ticker: [] },
41
+ correlations: [],
42
+ exposure_ratio: 0,
43
+ position_count: 0,
44
+ recommendations: {
45
+ max_new_position_size: balance.balance / 100,
46
+ max_single_position_pct: flags.threshold,
47
+ },
48
+ risk_level: 'LOW',
49
+ risk_score: 0,
50
+ total_exposure: 0,
51
+ });
52
+ }
53
+ else {
54
+ this.log(chalk.yellow('No open positions'));
55
+ }
56
+ return;
57
+ }
58
+ const portfolioValue = balance.portfolio_value / 100;
59
+ // Fetch market details for all positions to enable grouping
60
+ const tickers = positions.map(p => p.ticker).join(',');
61
+ const marketsResult = await client.getMarkets({ limit: positions.length, tickers });
62
+ const { markets } = marketsResult;
63
+ // Calculate risk metrics
64
+ const totalExposure = positions.reduce((sum, pos) => sum + pos.market_exposure, 0);
65
+ const exposureRatio = portfolioValue > 0 ? totalExposure / portfolioValue : 0;
66
+ const riskScore = calculatePortfolioRiskScore(positions, portfolioValue);
67
+ const highRiskPositions = identifyHighRiskPositions(positions, portfolioValue, flags.threshold);
68
+ // Concentration analysis
69
+ const concentrationByTicker = calculateConcentrationByGroup(positions, markets, portfolioValue, 'ticker', flags.threshold);
70
+ const concentrationByEvent = calculateConcentrationByGroup(positions, markets, portfolioValue, 'event', flags.threshold);
71
+ // Detect correlations
72
+ const correlations = detectCorrelatedPositions(positions, markets);
73
+ const correlationWarnings = [...correlations.entries()].map(([event, data]) => ({
74
+ event,
75
+ markets: data.markets,
76
+ total_exposure: data.exposure,
77
+ warning: 'Multiple positions in same event',
78
+ }));
79
+ // Identify alerts
80
+ const alerts = [];
81
+ if (riskScore.level === 'HIGH') {
82
+ alerts.push({
83
+ message: `High portfolio risk score: ${riskScore.score}/100`,
84
+ severity: 'high',
85
+ });
86
+ }
87
+ for (const pos of highRiskPositions) {
88
+ const pct = (pos.market_exposure / portfolioValue) * 100;
89
+ alerts.push({
90
+ message: `${pos.ticker} exceeds threshold: ${pct.toFixed(1)}% of portfolio`,
91
+ severity: pct > flags.threshold * 1.5 ? 'high' : 'medium',
92
+ });
93
+ }
94
+ if (exposureRatio > 0.8) {
95
+ alerts.push({
96
+ message: `High exposure ratio: ${(exposureRatio * 100).toFixed(1)}%`,
97
+ severity: exposureRatio > 1 ? 'high' : 'medium',
98
+ });
99
+ }
100
+ // Recommendations
101
+ const maxNewPositionSize = suggestMaxPositionSize(balance.balance / 100, portfolioValue, flags.threshold);
102
+ if (this.formatter.isJSONMode()) {
103
+ this.formatter.success({
104
+ alerts,
105
+ concentrations: {
106
+ by_event: concentrationByEvent,
107
+ by_ticker: concentrationByTicker,
108
+ },
109
+ correlations: correlationWarnings,
110
+ exposure_ratio: exposureRatio,
111
+ position_count: positions.length,
112
+ recommendations: {
113
+ max_new_position_size: maxNewPositionSize,
114
+ max_single_position_pct: flags.threshold,
115
+ },
116
+ risk_level: riskScore.level,
117
+ risk_score: riskScore.score,
118
+ total_exposure: totalExposure,
119
+ });
120
+ }
121
+ else {
122
+ // Human-readable output
123
+ this.log(chalk.cyan.bold('Portfolio Risk Analysis'));
124
+ this.log();
125
+ // Overall Risk
126
+ const riskLevelColor = riskScore.level === 'HIGH' ? chalk.red : riskScore.level === 'MEDIUM' ? chalk.yellow : chalk.green;
127
+ this.log(chalk.yellow('Overall Risk:'));
128
+ this.log(` Risk Score: ${riskLevelColor(riskScore.score + '/100')}`);
129
+ this.log(` Risk Level: ${riskLevelColor(riskScore.level)}`);
130
+ this.log(` Total Exposure: ${chalk.cyan('$' + totalExposure.toFixed(2))}`);
131
+ this.log(` Exposure Ratio: ${exposureRatio > 0.8 ? chalk.red((exposureRatio * 100).toFixed(1) + '%') : chalk.cyan((exposureRatio * 100).toFixed(1) + '%')}`);
132
+ this.log(` Positions: ${positions.length}`);
133
+ this.log();
134
+ // Alerts
135
+ if (alerts.length > 0) {
136
+ this.log(chalk.yellow('⚠️ Alerts:'));
137
+ for (const alert of alerts) {
138
+ const color = alert.severity === 'high' ? chalk.red : alert.severity === 'medium' ? chalk.yellow : chalk.blue;
139
+ this.log(` ${color('•')} ${alert.message}`);
140
+ }
141
+ this.log();
142
+ }
143
+ // Concentration
144
+ this.log(chalk.yellow('Concentration by Ticker:'));
145
+ const topConcentrations = concentrationByTicker.slice(0, 10);
146
+ if (topConcentrations.length > 0) {
147
+ const rows = topConcentrations.map(c => [
148
+ c.name,
149
+ '$' + c.exposure.toFixed(2),
150
+ c.pct_of_portfolio.toFixed(1) + '%',
151
+ c.alert ? chalk.red('⚠️ ALERT') : chalk.green('OK'),
152
+ ]);
153
+ this.formatter.outputTable(['Ticker', 'Exposure', '% of Portfolio', 'Status'], rows);
154
+ }
155
+ else {
156
+ this.log(' No positions');
157
+ }
158
+ this.log();
159
+ // Event concentration
160
+ if (concentrationByEvent.length > 0) {
161
+ this.log(chalk.yellow('Concentration by Event:'));
162
+ const topEvents = concentrationByEvent.slice(0, 5);
163
+ const eventRows = topEvents.map(c => [
164
+ c.name,
165
+ (c.markets || []).length.toString() + ' markets',
166
+ '$' + c.exposure.toFixed(2),
167
+ c.pct_of_portfolio.toFixed(1) + '%',
168
+ c.alert ? chalk.red('⚠️ ALERT') : chalk.green('OK'),
169
+ ]);
170
+ this.formatter.outputTable(['Event', 'Markets', 'Exposure', '% of Portfolio', 'Status'], eventRows);
171
+ this.log();
172
+ }
173
+ // Correlations
174
+ if (correlationWarnings.length > 0) {
175
+ this.log(chalk.yellow('⚠️ Correlation Warnings:'));
176
+ for (const corr of correlationWarnings) {
177
+ this.log(` ${chalk.cyan(corr.event)}: ${corr.markets.length} positions, $${corr.total_exposure.toFixed(2)} total exposure`);
178
+ this.log(` Markets: ${corr.markets.join(', ')}`);
179
+ }
180
+ this.log();
181
+ }
182
+ // Recommendations
183
+ this.log(chalk.yellow('Recommendations:'));
184
+ this.log(` Max New Position Size: ${chalk.cyan('$' + maxNewPositionSize.toFixed(2))} (${flags.threshold}% of portfolio)`);
185
+ this.log(` Position Size Threshold: ${chalk.cyan(flags.threshold + '%')}`);
186
+ if (riskScore.level === 'HIGH') {
187
+ this.log();
188
+ this.log(chalk.red('⚠️ Consider reducing exposure or diversifying positions'));
189
+ }
190
+ else if (riskScore.level === 'MEDIUM') {
191
+ this.log();
192
+ this.log(chalk.yellow('ℹ️ Monitor positions and avoid over-concentration'));
193
+ }
194
+ }
195
+ logger.info({
196
+ alerts: alerts.length,
197
+ correlations: correlationWarnings.length,
198
+ risk_level: riskScore.level,
199
+ risk_score: riskScore.score,
200
+ }, 'Risk analysis completed');
201
+ }
202
+ catch (error) {
203
+ throw error;
204
+ }
205
+ }
206
+ }