@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.
- package/README.md +249 -12
- package/dist/commands/markets/scan.d.ts +18 -0
- package/dist/commands/markets/scan.js +237 -0
- package/dist/commands/portfolio/analytics.d.ts +12 -0
- package/dist/commands/portfolio/analytics.js +192 -0
- package/dist/commands/portfolio/history.d.ts +14 -0
- package/dist/commands/portfolio/history.js +245 -0
- package/dist/commands/portfolio/risk.d.ts +11 -0
- package/dist/commands/portfolio/risk.js +206 -0
- package/dist/lib/analytics.d.ts +64 -0
- package/dist/lib/analytics.js +236 -0
- package/dist/lib/risk.d.ts +51 -0
- package/dist/lib/risk.js +153 -0
- package/dist/lib/scanner.d.ts +58 -0
- package/dist/lib/scanner.js +160 -0
- package/docs/TRADING_STRATEGIES.md +538 -0
- package/oclif.manifest.json +486 -188
- package/package.json +1 -1
|
@@ -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
|
+
}
|