@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portfolio analytics calculation utilities
|
|
3
|
+
*/
|
|
4
|
+
import type { Fill, Position } from './kalshi/types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Calculate win rate from a set of fills
|
|
7
|
+
* Matches buys and sells for the same ticker to determine wins/losses
|
|
8
|
+
*/
|
|
9
|
+
export declare function calculateWinRate(fills: Fill[]): number;
|
|
10
|
+
/**
|
|
11
|
+
* Calculate average P&L for winning and losing trades
|
|
12
|
+
*/
|
|
13
|
+
export declare function calculateAveragePnL(fills: Fill[]): {
|
|
14
|
+
avgLoss: number;
|
|
15
|
+
avgWin: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Calculate portfolio concentration by ticker
|
|
19
|
+
* Returns map of ticker to percentage of portfolio value
|
|
20
|
+
*/
|
|
21
|
+
export declare function calculatePortfolioConcentration(positions: Position[], portfolioValue: number): Map<string, number>;
|
|
22
|
+
/**
|
|
23
|
+
* Calculate exposure ratio (total market exposure / portfolio value)
|
|
24
|
+
*/
|
|
25
|
+
export declare function calculateExposureRatio(positions: Position[], portfolioValue: number): number;
|
|
26
|
+
/**
|
|
27
|
+
* Group fills by time period (day, week, or month)
|
|
28
|
+
*/
|
|
29
|
+
export declare function groupFillsByPeriod(fills: Fill[], period: 'day' | 'month' | 'week'): Map<string, Fill[]>;
|
|
30
|
+
/**
|
|
31
|
+
* Estimate total P&L from fills by matching buys and sells
|
|
32
|
+
* Note: This is an estimate based on fills alone, actual P&L should come from positions
|
|
33
|
+
*/
|
|
34
|
+
export declare function estimatePnLFromFills(fills: Fill[]): number;
|
|
35
|
+
/**
|
|
36
|
+
* Calculate total fees paid from fills
|
|
37
|
+
* Fees are calculated based on whether the fill was a maker or taker
|
|
38
|
+
*/
|
|
39
|
+
export declare function calculateTotalFees(fills: Fill[]): number;
|
|
40
|
+
/**
|
|
41
|
+
* Group fills by ticker and calculate statistics
|
|
42
|
+
*/
|
|
43
|
+
export declare function groupFillsByTicker(fills: Fill[]): Map<string, {
|
|
44
|
+
buyVolume: number;
|
|
45
|
+
count: number;
|
|
46
|
+
estimatedPnL: number;
|
|
47
|
+
sellVolume: number;
|
|
48
|
+
volume: number;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* Calculate maker vs taker statistics
|
|
52
|
+
*/
|
|
53
|
+
export declare function calculateMakerTakerStats(fills: Fill[]): {
|
|
54
|
+
maker: {
|
|
55
|
+
avgFeePct: number;
|
|
56
|
+
fills: number;
|
|
57
|
+
volume: number;
|
|
58
|
+
};
|
|
59
|
+
taker: {
|
|
60
|
+
avgFeePct: number;
|
|
61
|
+
fills: number;
|
|
62
|
+
volume: number;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portfolio analytics calculation utilities
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Calculate win rate from a set of fills
|
|
6
|
+
* Matches buys and sells for the same ticker to determine wins/losses
|
|
7
|
+
*/
|
|
8
|
+
export function calculateWinRate(fills) {
|
|
9
|
+
if (fills.length === 0)
|
|
10
|
+
return 0;
|
|
11
|
+
// Group fills by ticker
|
|
12
|
+
const fillsByTicker = new Map();
|
|
13
|
+
for (const fill of fills) {
|
|
14
|
+
const existing = fillsByTicker.get(fill.ticker) || [];
|
|
15
|
+
existing.push(fill);
|
|
16
|
+
fillsByTicker.set(fill.ticker, existing);
|
|
17
|
+
}
|
|
18
|
+
let winningTrades = 0;
|
|
19
|
+
let losingTrades = 0;
|
|
20
|
+
// For each ticker, match buys and sells to calculate P&L
|
|
21
|
+
for (const [, tickerFills] of fillsByTicker) {
|
|
22
|
+
const sorted = tickerFills.sort((a, b) => new Date(a.created_time).getTime() - new Date(b.created_time).getTime());
|
|
23
|
+
let position = 0;
|
|
24
|
+
let totalCost = 0;
|
|
25
|
+
for (const fill of sorted) {
|
|
26
|
+
const quantity = fill.action === 'buy' ? fill.count : -fill.count;
|
|
27
|
+
const cost = fill.action === 'buy' ? fill.count * fill.price : -fill.count * fill.price;
|
|
28
|
+
if (fill.action === 'sell' && position > 0) {
|
|
29
|
+
// Closing a position - calculate P&L
|
|
30
|
+
const avgEntryPrice = totalCost / position;
|
|
31
|
+
const sellPrice = fill.price;
|
|
32
|
+
const pnl = (sellPrice - avgEntryPrice) * fill.count;
|
|
33
|
+
if (pnl > 0)
|
|
34
|
+
winningTrades++;
|
|
35
|
+
else if (pnl < 0)
|
|
36
|
+
losingTrades++;
|
|
37
|
+
}
|
|
38
|
+
position += quantity;
|
|
39
|
+
totalCost += cost;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const totalTrades = winningTrades + losingTrades;
|
|
43
|
+
return totalTrades > 0 ? winningTrades / totalTrades : 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Calculate average P&L for winning and losing trades
|
|
47
|
+
*/
|
|
48
|
+
export function calculateAveragePnL(fills) {
|
|
49
|
+
if (fills.length === 0)
|
|
50
|
+
return { avgLoss: 0, avgWin: 0 };
|
|
51
|
+
// Group fills by ticker
|
|
52
|
+
const fillsByTicker = new Map();
|
|
53
|
+
for (const fill of fills) {
|
|
54
|
+
const existing = fillsByTicker.get(fill.ticker) || [];
|
|
55
|
+
existing.push(fill);
|
|
56
|
+
fillsByTicker.set(fill.ticker, existing);
|
|
57
|
+
}
|
|
58
|
+
const wins = [];
|
|
59
|
+
const losses = [];
|
|
60
|
+
// For each ticker, match buys and sells to calculate P&L
|
|
61
|
+
for (const [, tickerFills] of fillsByTicker) {
|
|
62
|
+
const sorted = tickerFills.sort((a, b) => new Date(a.created_time).getTime() - new Date(b.created_time).getTime());
|
|
63
|
+
let position = 0;
|
|
64
|
+
let totalCost = 0;
|
|
65
|
+
for (const fill of sorted) {
|
|
66
|
+
const quantity = fill.action === 'buy' ? fill.count : -fill.count;
|
|
67
|
+
const cost = fill.action === 'buy' ? fill.count * fill.price : -fill.count * fill.price;
|
|
68
|
+
if (fill.action === 'sell' && position > 0) {
|
|
69
|
+
// Closing a position - calculate P&L
|
|
70
|
+
const avgEntryPrice = totalCost / position;
|
|
71
|
+
const sellPrice = fill.price;
|
|
72
|
+
const pnl = (sellPrice - avgEntryPrice) * fill.count;
|
|
73
|
+
if (pnl > 0)
|
|
74
|
+
wins.push(pnl);
|
|
75
|
+
else if (pnl < 0)
|
|
76
|
+
losses.push(pnl);
|
|
77
|
+
}
|
|
78
|
+
position += quantity;
|
|
79
|
+
totalCost += cost;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const avgWin = wins.length > 0 ? wins.reduce((sum, w) => sum + w, 0) / wins.length : 0;
|
|
83
|
+
const avgLoss = losses.length > 0 ? losses.reduce((sum, l) => sum + l, 0) / losses.length : 0;
|
|
84
|
+
return { avgLoss, avgWin };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Calculate portfolio concentration by ticker
|
|
88
|
+
* Returns map of ticker to percentage of portfolio value
|
|
89
|
+
*/
|
|
90
|
+
export function calculatePortfolioConcentration(positions, portfolioValue) {
|
|
91
|
+
const concentration = new Map();
|
|
92
|
+
if (portfolioValue <= 0)
|
|
93
|
+
return concentration;
|
|
94
|
+
for (const position of positions) {
|
|
95
|
+
const percentage = (position.market_exposure / portfolioValue) * 100;
|
|
96
|
+
concentration.set(position.ticker, percentage);
|
|
97
|
+
}
|
|
98
|
+
return concentration;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Calculate exposure ratio (total market exposure / portfolio value)
|
|
102
|
+
*/
|
|
103
|
+
export function calculateExposureRatio(positions, portfolioValue) {
|
|
104
|
+
if (portfolioValue <= 0)
|
|
105
|
+
return 0;
|
|
106
|
+
const totalExposure = positions.reduce((sum, pos) => sum + pos.market_exposure, 0);
|
|
107
|
+
return totalExposure / portfolioValue;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Group fills by time period (day, week, or month)
|
|
111
|
+
*/
|
|
112
|
+
export function groupFillsByPeriod(fills, period) {
|
|
113
|
+
const grouped = new Map();
|
|
114
|
+
for (const fill of fills) {
|
|
115
|
+
const date = new Date(fill.created_time);
|
|
116
|
+
let key;
|
|
117
|
+
if (period === 'day') {
|
|
118
|
+
key = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
119
|
+
}
|
|
120
|
+
else if (period === 'week') {
|
|
121
|
+
// Get ISO week number
|
|
122
|
+
const onejan = new Date(date.getFullYear(), 0, 1);
|
|
123
|
+
const weekNumber = Math.ceil((((date.getTime() - onejan.getTime()) / 86_400_000) + onejan.getDay() + 1) / 7);
|
|
124
|
+
key = `${date.getFullYear()}-W${weekNumber.toString().padStart(2, '0')}`;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// month
|
|
128
|
+
key = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
|
|
129
|
+
}
|
|
130
|
+
const existing = grouped.get(key) || [];
|
|
131
|
+
existing.push(fill);
|
|
132
|
+
grouped.set(key, existing);
|
|
133
|
+
}
|
|
134
|
+
return grouped;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Estimate total P&L from fills by matching buys and sells
|
|
138
|
+
* Note: This is an estimate based on fills alone, actual P&L should come from positions
|
|
139
|
+
*/
|
|
140
|
+
export function estimatePnLFromFills(fills) {
|
|
141
|
+
// Group fills by ticker
|
|
142
|
+
const fillsByTicker = new Map();
|
|
143
|
+
for (const fill of fills) {
|
|
144
|
+
const existing = fillsByTicker.get(fill.ticker) || [];
|
|
145
|
+
existing.push(fill);
|
|
146
|
+
fillsByTicker.set(fill.ticker, existing);
|
|
147
|
+
}
|
|
148
|
+
let totalPnL = 0;
|
|
149
|
+
// For each ticker, match buys and sells to calculate P&L
|
|
150
|
+
for (const [, tickerFills] of fillsByTicker) {
|
|
151
|
+
const sorted = tickerFills.sort((a, b) => new Date(a.created_time).getTime() - new Date(b.created_time).getTime());
|
|
152
|
+
let position = 0;
|
|
153
|
+
let totalCost = 0;
|
|
154
|
+
for (const fill of sorted) {
|
|
155
|
+
const quantity = fill.action === 'buy' ? fill.count : -fill.count;
|
|
156
|
+
const cost = fill.action === 'buy' ? fill.count * fill.price : -fill.count * fill.price;
|
|
157
|
+
if (fill.action === 'sell' && position > 0) {
|
|
158
|
+
// Closing a position - calculate P&L
|
|
159
|
+
const avgEntryPrice = totalCost / position;
|
|
160
|
+
const sellPrice = fill.price;
|
|
161
|
+
const pnl = (sellPrice - avgEntryPrice) * fill.count;
|
|
162
|
+
totalPnL += pnl;
|
|
163
|
+
}
|
|
164
|
+
position += quantity;
|
|
165
|
+
totalCost += cost;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return totalPnL;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Calculate total fees paid from fills
|
|
172
|
+
* Fees are calculated based on whether the fill was a maker or taker
|
|
173
|
+
*/
|
|
174
|
+
export function calculateTotalFees(fills) {
|
|
175
|
+
// Kalshi fee structure (approximate):
|
|
176
|
+
// Maker: 0.5% (0.005)
|
|
177
|
+
// Taker: 1.5% (0.015)
|
|
178
|
+
const MAKER_FEE = 0.005;
|
|
179
|
+
const TAKER_FEE = 0.015;
|
|
180
|
+
let totalFees = 0;
|
|
181
|
+
for (const fill of fills) {
|
|
182
|
+
const notionalValue = fill.count * fill.price;
|
|
183
|
+
const feeRate = fill.is_taker ? TAKER_FEE : MAKER_FEE;
|
|
184
|
+
totalFees += notionalValue * feeRate;
|
|
185
|
+
}
|
|
186
|
+
return totalFees;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Group fills by ticker and calculate statistics
|
|
190
|
+
*/
|
|
191
|
+
export function groupFillsByTicker(fills) {
|
|
192
|
+
const grouped = new Map();
|
|
193
|
+
const fillsByTicker = new Map();
|
|
194
|
+
for (const fill of fills) {
|
|
195
|
+
const existing = fillsByTicker.get(fill.ticker) || [];
|
|
196
|
+
existing.push(fill);
|
|
197
|
+
fillsByTicker.set(fill.ticker, existing);
|
|
198
|
+
}
|
|
199
|
+
for (const [ticker, tickerFills] of fillsByTicker) {
|
|
200
|
+
const volume = tickerFills.reduce((sum, f) => sum + f.count * f.price, 0);
|
|
201
|
+
const buyVolume = tickerFills.filter(f => f.action === 'buy').reduce((sum, f) => sum + f.count * f.price, 0);
|
|
202
|
+
const sellVolume = tickerFills.filter(f => f.action === 'sell').reduce((sum, f) => sum + f.count * f.price, 0);
|
|
203
|
+
const estimatedPnL = estimatePnLFromFills(tickerFills);
|
|
204
|
+
grouped.set(ticker, {
|
|
205
|
+
buyVolume,
|
|
206
|
+
count: tickerFills.length,
|
|
207
|
+
estimatedPnL,
|
|
208
|
+
sellVolume,
|
|
209
|
+
volume,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return grouped;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Calculate maker vs taker statistics
|
|
216
|
+
*/
|
|
217
|
+
export function calculateMakerTakerStats(fills) {
|
|
218
|
+
const MAKER_FEE = 0.005;
|
|
219
|
+
const TAKER_FEE = 0.015;
|
|
220
|
+
const makerFills = fills.filter(f => !f.is_taker);
|
|
221
|
+
const takerFills = fills.filter(f => f.is_taker);
|
|
222
|
+
const makerVolume = makerFills.reduce((sum, f) => sum + f.count * f.price, 0);
|
|
223
|
+
const takerVolume = takerFills.reduce((sum, f) => sum + f.count * f.price, 0);
|
|
224
|
+
return {
|
|
225
|
+
maker: {
|
|
226
|
+
avgFeePct: MAKER_FEE,
|
|
227
|
+
fills: makerFills.length,
|
|
228
|
+
volume: makerVolume,
|
|
229
|
+
},
|
|
230
|
+
taker: {
|
|
231
|
+
avgFeePct: TAKER_FEE,
|
|
232
|
+
fills: takerFills.length,
|
|
233
|
+
volume: takerVolume,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk management calculation utilities
|
|
3
|
+
*/
|
|
4
|
+
import type { Market, Position } from './kalshi/types.js';
|
|
5
|
+
export type RiskLevel = 'HIGH' | 'LOW' | 'MEDIUM';
|
|
6
|
+
/**
|
|
7
|
+
* Calculate risk score for a single position (0-100)
|
|
8
|
+
* Higher score = higher risk
|
|
9
|
+
*/
|
|
10
|
+
export declare function calculatePositionRiskScore(position: Position, portfolioValue: number, threshold: number): number;
|
|
11
|
+
/**
|
|
12
|
+
* Detect correlated positions by grouping them by event or series
|
|
13
|
+
* Returns map of event/series ticker to positions and total exposure
|
|
14
|
+
*/
|
|
15
|
+
export declare function detectCorrelatedPositions(positions: Position[], markets: Market[]): Map<string, {
|
|
16
|
+
exposure: number;
|
|
17
|
+
markets: string[];
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Suggest maximum safe position size based on portfolio value and risk tolerance
|
|
21
|
+
*/
|
|
22
|
+
export declare function suggestMaxPositionSize(balance: number, portfolioValue: number, maxPct: number): number;
|
|
23
|
+
/**
|
|
24
|
+
* Group positions by event, series, or ticker
|
|
25
|
+
*/
|
|
26
|
+
export declare function groupPositionsBy(positions: Position[], markets: Market[], groupBy: 'event' | 'series' | 'ticker'): Map<string, {
|
|
27
|
+
positions: Position[];
|
|
28
|
+
totalExposure: number;
|
|
29
|
+
}>;
|
|
30
|
+
/**
|
|
31
|
+
* Calculate overall portfolio risk score (0-100) and categorize risk level
|
|
32
|
+
*/
|
|
33
|
+
export declare function calculatePortfolioRiskScore(positions: Position[], portfolioValue: number): {
|
|
34
|
+
level: RiskLevel;
|
|
35
|
+
score: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Identify positions that exceed a risk threshold
|
|
39
|
+
*/
|
|
40
|
+
export declare function identifyHighRiskPositions(positions: Position[], portfolioValue: number, threshold: number): Position[];
|
|
41
|
+
/**
|
|
42
|
+
* Calculate concentration by grouping
|
|
43
|
+
* Returns sorted array of concentrations
|
|
44
|
+
*/
|
|
45
|
+
export declare function calculateConcentrationByGroup(positions: Position[], markets: Market[], portfolioValue: number, groupBy: 'event' | 'series' | 'ticker', threshold: number): Array<{
|
|
46
|
+
alert: boolean;
|
|
47
|
+
exposure: number;
|
|
48
|
+
markets?: string[];
|
|
49
|
+
name: string;
|
|
50
|
+
pct_of_portfolio: number;
|
|
51
|
+
}>;
|
package/dist/lib/risk.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk management calculation utilities
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Calculate risk score for a single position (0-100)
|
|
6
|
+
* Higher score = higher risk
|
|
7
|
+
*/
|
|
8
|
+
export function calculatePositionRiskScore(position, portfolioValue, threshold) {
|
|
9
|
+
if (portfolioValue <= 0)
|
|
10
|
+
return 0;
|
|
11
|
+
const concentrationPct = (position.market_exposure / portfolioValue) * 100;
|
|
12
|
+
// Risk increases exponentially as concentration approaches/exceeds threshold
|
|
13
|
+
let score = (concentrationPct / threshold) * 50;
|
|
14
|
+
// Additional risk if position is unrealized loss
|
|
15
|
+
if (position.realized_pnl < 0) {
|
|
16
|
+
const lossRatio = Math.abs(position.realized_pnl) / position.total_cost;
|
|
17
|
+
score += lossRatio * 25;
|
|
18
|
+
}
|
|
19
|
+
return Math.min(100, Math.max(0, score));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Detect correlated positions by grouping them by event or series
|
|
23
|
+
* Returns map of event/series ticker to positions and total exposure
|
|
24
|
+
*/
|
|
25
|
+
export function detectCorrelatedPositions(positions, markets) {
|
|
26
|
+
const correlations = new Map();
|
|
27
|
+
// Create a map of ticker to market for quick lookup
|
|
28
|
+
const marketMap = new Map(markets.map(m => [m.ticker, m]));
|
|
29
|
+
// Group positions by event ticker
|
|
30
|
+
for (const position of positions) {
|
|
31
|
+
const market = marketMap.get(position.ticker);
|
|
32
|
+
if (!market)
|
|
33
|
+
continue;
|
|
34
|
+
const eventTicker = market.event_ticker;
|
|
35
|
+
const existing = correlations.get(eventTicker) || { exposure: 0, markets: [] };
|
|
36
|
+
existing.markets.push(position.ticker);
|
|
37
|
+
existing.exposure += position.market_exposure;
|
|
38
|
+
correlations.set(eventTicker, existing);
|
|
39
|
+
}
|
|
40
|
+
// Filter to only events with multiple positions
|
|
41
|
+
const filtered = new Map();
|
|
42
|
+
for (const [eventTicker, data] of correlations) {
|
|
43
|
+
if (data.markets.length > 1) {
|
|
44
|
+
filtered.set(eventTicker, data);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return filtered;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Suggest maximum safe position size based on portfolio value and risk tolerance
|
|
51
|
+
*/
|
|
52
|
+
export function suggestMaxPositionSize(balance, portfolioValue, maxPct) {
|
|
53
|
+
if (portfolioValue <= 0)
|
|
54
|
+
return balance;
|
|
55
|
+
return (portfolioValue * maxPct) / 100;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Group positions by event, series, or ticker
|
|
59
|
+
*/
|
|
60
|
+
export function groupPositionsBy(positions, markets, groupBy) {
|
|
61
|
+
const grouped = new Map();
|
|
62
|
+
// Create a map of ticker to market for quick lookup
|
|
63
|
+
const marketMap = new Map(markets.map(m => [m.ticker, m]));
|
|
64
|
+
for (const position of positions) {
|
|
65
|
+
let key;
|
|
66
|
+
if (groupBy === 'ticker') {
|
|
67
|
+
key = position.ticker;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const market = marketMap.get(position.ticker);
|
|
71
|
+
if (!market)
|
|
72
|
+
continue;
|
|
73
|
+
key = groupBy === 'event' ? market.event_ticker : market.event_ticker;
|
|
74
|
+
// series - extract from event ticker (format: SERIES-SPECIFIC or just use event_ticker)
|
|
75
|
+
// For now, use event_ticker as series identifier
|
|
76
|
+
}
|
|
77
|
+
const existing = grouped.get(key) || { positions: [], totalExposure: 0 };
|
|
78
|
+
existing.positions.push(position);
|
|
79
|
+
existing.totalExposure += position.market_exposure;
|
|
80
|
+
grouped.set(key, existing);
|
|
81
|
+
}
|
|
82
|
+
return grouped;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Calculate overall portfolio risk score (0-100) and categorize risk level
|
|
86
|
+
*/
|
|
87
|
+
export function calculatePortfolioRiskScore(positions, portfolioValue) {
|
|
88
|
+
if (positions.length === 0 || portfolioValue <= 0) {
|
|
89
|
+
return { level: 'LOW', score: 0 };
|
|
90
|
+
}
|
|
91
|
+
// Calculate exposure ratio
|
|
92
|
+
const totalExposure = positions.reduce((sum, pos) => sum + pos.market_exposure, 0);
|
|
93
|
+
const exposureRatio = totalExposure / portfolioValue;
|
|
94
|
+
// Calculate concentration (largest position as % of portfolio)
|
|
95
|
+
const largestExposure = Math.max(...positions.map(pos => pos.market_exposure));
|
|
96
|
+
const largestConcentration = (largestExposure / portfolioValue) * 100;
|
|
97
|
+
// Calculate unrealized loss ratio
|
|
98
|
+
const totalUnrealizedLoss = positions
|
|
99
|
+
.filter(pos => pos.realized_pnl < 0)
|
|
100
|
+
.reduce((sum, pos) => sum + Math.abs(pos.realized_pnl), 0);
|
|
101
|
+
const lossRatio = totalExposure > 0 ? totalUnrealizedLoss / totalExposure : 0;
|
|
102
|
+
// Weighted risk score
|
|
103
|
+
let score = 0;
|
|
104
|
+
// Exposure contributes up to 40 points (0.5 exposure = 20 points, 1.0 = 40 points)
|
|
105
|
+
score += Math.min(40, exposureRatio * 40);
|
|
106
|
+
// Concentration contributes up to 30 points (20% = 15 points, 40% = 30 points)
|
|
107
|
+
score += Math.min(30, (largestConcentration / 40) * 30);
|
|
108
|
+
// Loss ratio contributes up to 30 points
|
|
109
|
+
score += Math.min(30, lossRatio * 100 * 0.3);
|
|
110
|
+
// Categorize risk level
|
|
111
|
+
let level;
|
|
112
|
+
if (score < 30)
|
|
113
|
+
level = 'LOW';
|
|
114
|
+
else if (score < 60)
|
|
115
|
+
level = 'MEDIUM';
|
|
116
|
+
else
|
|
117
|
+
level = 'HIGH';
|
|
118
|
+
return { level, score: Math.round(score) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Identify positions that exceed a risk threshold
|
|
122
|
+
*/
|
|
123
|
+
export function identifyHighRiskPositions(positions, portfolioValue, threshold) {
|
|
124
|
+
if (portfolioValue <= 0)
|
|
125
|
+
return [];
|
|
126
|
+
return positions.filter(pos => {
|
|
127
|
+
const concentrationPct = (pos.market_exposure / portfolioValue) * 100;
|
|
128
|
+
return concentrationPct > threshold;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Calculate concentration by grouping
|
|
133
|
+
* Returns sorted array of concentrations
|
|
134
|
+
*/
|
|
135
|
+
// eslint-disable-next-line max-params
|
|
136
|
+
export function calculateConcentrationByGroup(positions, markets, portfolioValue, groupBy, threshold) {
|
|
137
|
+
if (portfolioValue <= 0)
|
|
138
|
+
return [];
|
|
139
|
+
const grouped = groupPositionsBy(positions, markets, groupBy);
|
|
140
|
+
const concentrations = [];
|
|
141
|
+
for (const [name, data] of grouped) {
|
|
142
|
+
const pctOfPortfolio = (data.totalExposure / portfolioValue) * 100;
|
|
143
|
+
concentrations.push({
|
|
144
|
+
alert: pctOfPortfolio > threshold,
|
|
145
|
+
exposure: data.totalExposure,
|
|
146
|
+
markets: groupBy === 'ticker' ? undefined : data.positions.map(p => p.ticker),
|
|
147
|
+
name,
|
|
148
|
+
pct_of_portfolio: pctOfPortfolio,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Sort by exposure descending
|
|
152
|
+
return concentrations.sort((a, b) => b.exposure - a.exposure);
|
|
153
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Market scanning and opportunity detection utilities
|
|
3
|
+
*/
|
|
4
|
+
import type { Market, OrderBook } from './kalshi/types.js';
|
|
5
|
+
export type OpportunityType = 'balanced' | 'high_liquidity' | 'high_volume' | 'wide_spread';
|
|
6
|
+
/**
|
|
7
|
+
* Calculate total liquidity from an orderbook
|
|
8
|
+
*/
|
|
9
|
+
export declare function calculateOrderbookLiquidity(orderbook: OrderBook): {
|
|
10
|
+
noTotal: number;
|
|
11
|
+
total: number;
|
|
12
|
+
yesTotal: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Calculate bid-ask spread from an orderbook
|
|
16
|
+
*
|
|
17
|
+
* In Kalshi orderbooks:
|
|
18
|
+
* - Lower prices in the yes array are bids (buyers)
|
|
19
|
+
* - Higher prices in the yes array are asks (sellers)
|
|
20
|
+
* - Spread = ask - bid
|
|
21
|
+
*/
|
|
22
|
+
export declare function calculateSpread(orderbook: OrderBook): null | {
|
|
23
|
+
spreadCents: number;
|
|
24
|
+
spreadPct: number;
|
|
25
|
+
yesAsk: number;
|
|
26
|
+
yesBid: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Score a market based on liquidity, spread, and volume
|
|
30
|
+
* Returns score from 0-100
|
|
31
|
+
*/
|
|
32
|
+
export declare function scoreMarket(market: Market, orderbook: OrderBook, weights?: {
|
|
33
|
+
liquidity: number;
|
|
34
|
+
spread: number;
|
|
35
|
+
volume: number;
|
|
36
|
+
}): number;
|
|
37
|
+
/**
|
|
38
|
+
* Classify the type of opportunity a market presents
|
|
39
|
+
*/
|
|
40
|
+
export declare function classifyOpportunity(spread: number, liquidity: number, volume: number): OpportunityType;
|
|
41
|
+
/**
|
|
42
|
+
* Generate human-readable reason for opportunity
|
|
43
|
+
*/
|
|
44
|
+
export declare function generateOpportunityReason(type: OpportunityType, spread: number, liquidity: number, volume: number): string;
|
|
45
|
+
/**
|
|
46
|
+
* Filter markets based on criteria
|
|
47
|
+
*/
|
|
48
|
+
export declare function filterMarketsByCriteria<T extends {
|
|
49
|
+
liquidity: number;
|
|
50
|
+
market: Market;
|
|
51
|
+
spread: null | number;
|
|
52
|
+
volume: number;
|
|
53
|
+
}>(markets: T[], criteria: {
|
|
54
|
+
maxSpread?: number;
|
|
55
|
+
minLiquidity?: number;
|
|
56
|
+
minSpread?: number;
|
|
57
|
+
minVolume?: number;
|
|
58
|
+
}): T[];
|