Skip to content

Commit 12ad5b6

Browse files
authored
Merge pull request #32 from pbeets/feat/batch6-api-updates
feat: align models with latest Kalshi API spec (batches 4-6)
2 parents fdd057d + 2ff6cfc commit 12ad5b6

39 files changed

+942
-743
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
119119

120120
### Fixed
121121

122+
- `TransferBetweenSubaccountsRequest` now exposes a `client_transfer_id()`
123+
builder method. Previously the `client_transfer_id` field was hardcoded to an
124+
empty string in the constructor with no way for users to set it.
122125
- `OrderbookAggregator` now drops delta messages that arrive before a snapshot
123126
instead of creating ghost orderbook entries. Previously, early deltas would
124127
insert empty uninitialized books into the state map, causing `full_book()` and

README.md

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ An unofficial Rust client library for the [Kalshi](https://kalshi.com) predictio
99

1010
## Key Features
1111

12-
This crate provides both REST API and WebSocket streaming capabilities:
13-
14-
- **REST Client**: Full coverage of the Kalshi API including portfolio management, order operations, market data, exchange status, and RFQ (Request for Quote) communications
15-
- **WebSocket Streaming**: Real-time ticker, trade, orderbook, fill, and RFQ/quote updates with channel-based message delivery
12+
- **REST Client**: Full coverage of 84 Kalshi API endpoints including portfolio management, order operations, market data, exchange status, historical data, and RFQ (Request for Quote) communications
13+
- **WebSocket Streaming**: 10 real-time channels — ticker, trade, orderbook, fill, order updates, position, RFQ/quote, order groups, market lifecycle, and multivariate
14+
- **Batch Operations**: Rate-limited `BatchManager` with automatic chunking, retry, and per-order subaccount support
15+
- **Orderbook Aggregation**: Live orderbook state from WebSocket delta streams with gap detection
16+
- **Subaccount Support**: Full subaccount filtering on orders, fills, positions, settlements, and balance queries
17+
- **Fixed-Point Fields**: `_fp` and `_dollars` fields throughout for precise decimal arithmetic without floating-point issues
1618

1719
## Getting Started
1820

@@ -142,21 +144,46 @@ let client = KalshiStreamClient::connect_with_strategy(&config, ConnectStrategy:
142144

143145
## Examples
144146

145-
See the [`examples/`](examples/) directory for working examples:
147+
See the [`examples/`](examples/) directory for 24 working examples:
148+
149+
### REST API
146150

147151
| Example | Description |
148152
|---------|-------------|
149-
| `portfolio` | REST API: account balance, positions |
150-
| `trading` | REST API: order creation, amendment, cancellation |
151-
| `markets` | REST API: market data queries |
152-
| `stream_ticker` | WebSocket: real-time price updates |
153-
| `stream_user_channels` | WebSocket: fills, positions, RFQ communications |
153+
| `portfolio` | Account balance, positions, fills, settlements |
154+
| `trading` | Order lifecycle: create, amend, decrease, cancel |
155+
| `markets` | Market queries, orderbook, trades, pagination |
156+
| `events` | Events, series, metadata, multivariate, candlesticks, forecasts |
157+
| `candlesticks` | OHLCV data with multiple periods and batch queries |
158+
| `historical` | Archived data: cutoff, markets, candlesticks, fills, orders |
159+
| `batch_orders` | Batch create/cancel with partial success handling |
160+
| `batch_manager` | Rate-limited batch operations with `BatchManager` |
161+
| `order_groups` | Order group creation, management, triggering |
162+
| `exchange_status` | Exchange status, schedule, announcements, fee changes |
163+
| `live_data` | Live milestone data (single and batch) |
164+
| `milestones` | Milestone metadata queries |
165+
| `structured_targets` | Structured target lookups with type breakdown |
154166
| `rfq_verify` | RFQ system verification (read-only) |
155-
| `stream_reconnect` | WebSocket reconnection patterns |
167+
| `search` | Search filters by category and sport |
168+
| `series_and_search` | Series metadata and search combined |
169+
170+
### WebSocket Streaming
171+
172+
| Example | Description |
173+
|---------|-------------|
174+
| `stream_ticker` | Real-time ticker and trade updates |
175+
| `stream_user_channels` | Fills, positions, RFQ communications |
176+
| `stream_reconnect` | Reconnection with resubscription |
177+
| `stream_firehose` | High-volume streaming pattern |
178+
| `stream_lifecycle` | Market lifecycle events |
179+
| `multi_channel_subscribe` | Multi-channel subscription management |
180+
| `orderbook_aggregator` | Live orderbook state from delta streams |
181+
| `test_auth` | Basic authentication verification |
156182

157183
```bash
158184
cargo run --example portfolio
159-
cargo run --example rfq_verify
185+
cargo run --example trading
186+
cargo run --example historical
160187
```
161188

162189
## Error Handling

examples/batch_orders.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
184184
if let Some(order) = &result.order {
185185
println!(
186186
" {} | CANCELED | reduced by: {} contracts",
187-
order.order_id,
188-
result.reduced_by.unwrap_or(0)
187+
order.order_id, result.reduced_by
189188
);
190189
canceled_count += 1;
191190
} else if let Some(error) = &result.error {
192191
println!(
193192
" {} | FAILED | {}: {}",
194-
result.order_id.as_deref().unwrap_or("?"),
195-
error.code,
196-
error.message
193+
result.order_id, error.code, error.message
197194
);
198195
failed_count += 1;
199196
}

examples/candlesticks.rs

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
4040
let market = markets
4141
.markets
4242
.iter()
43-
.find(|m| m.volume.unwrap_or(0) > 0)
43+
.find(|m| m.volume > 0)
4444
.unwrap_or(&markets.markets[0]);
4545

4646
let ticker = &market.ticker;
4747
let series_ticker = &market.event_ticker;
48-
let volume = market.volume.unwrap_or(0);
48+
let volume = market.volume;
4949

5050
println!("Selected market: {}", ticker);
5151
println!("Series: {}", series_ticker);
@@ -92,18 +92,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9292
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
9393
.unwrap_or_else(|| candle.end_period_ts.to_string());
9494

95-
let (open, high, low, close) = if let Some(price) = &candle.price {
96-
(
97-
price.open.map(|p| format!("${:.2}", cents_to_dollars(p))),
98-
price.high.map(|p| format!("${:.2}", cents_to_dollars(p))),
99-
price.low.map(|p| format!("${:.2}", cents_to_dollars(p))),
100-
price.close.map(|p| format!("${:.2}", cents_to_dollars(p))),
101-
)
102-
} else {
103-
(None, None, None, None)
104-
};
105-
106-
let volume = candle.volume.map(|v| v.to_string());
95+
let price = &candle.price;
96+
let open = price.open.map(|p| format!("${:.2}", cents_to_dollars(p)));
97+
let high = price.high.map(|p| format!("${:.2}", cents_to_dollars(p)));
98+
let low = price.low.map(|p| format!("${:.2}", cents_to_dollars(p)));
99+
let close = price.close.map(|p| format!("${:.2}", cents_to_dollars(p)));
107100

108101
println!(
109102
"{:<20} {:>8} {:>8} {:>8} {:>8} {:>8}",
@@ -112,7 +105,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
112105
high.as_deref().unwrap_or("-"),
113106
low.as_deref().unwrap_or("-"),
114107
close.as_deref().unwrap_or("-"),
115-
volume.as_deref().unwrap_or("-")
108+
candle.volume
116109
);
117110
}
118111

@@ -142,23 +135,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
142135
.map(|dt| dt.format("%H:%M:%S").to_string())
143136
.unwrap_or_else(|| candle.end_period_ts.to_string());
144137

145-
let yes_bid = candle
146-
.yes_bid
147-
.as_ref()
148-
.and_then(|b| b.close)
149-
.map(|p| format!("${:.2}", cents_to_dollars(p)));
150-
let yes_ask = candle
151-
.yes_ask
152-
.as_ref()
153-
.and_then(|a| a.close)
154-
.map(|p| format!("${:.2}", cents_to_dollars(p)));
138+
let yes_bid = format!("${:.2}", cents_to_dollars(candle.yes_bid.close));
139+
let yes_ask = format!("${:.2}", cents_to_dollars(candle.yes_ask.close));
155140

156-
println!(
157-
"{:<20} {:>12} {:>12}",
158-
timestamp,
159-
yes_bid.as_deref().unwrap_or("-"),
160-
yes_ask.as_deref().unwrap_or("-")
161-
);
141+
println!("{:<20} {:>12} {:>12}", timestamp, yes_bid, yes_ask);
162142
}
163143
}
164144
println!();
@@ -179,8 +159,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
179159
.map(|dt| dt.format("%Y-%m-%d").to_string())
180160
.unwrap_or_else(|| candle.end_period_ts.to_string());
181161

182-
let vol = candle.volume.unwrap_or(0);
183-
let oi = candle.open_interest.unwrap_or(0);
162+
let vol = candle.volume;
163+
let oi = candle.open_interest;
184164

185165
println!(" {} | Volume: {} | Open Interest: {}", date, vol, oi);
186166
}

examples/events.rs

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
3333

3434
println!("Total events returned: {}", events.events.len());
3535

36-
if let Some(cursor) = &events.cursor {
37-
println!("Next cursor: {}...", &cursor[..cursor.len().min(20)]);
36+
if !events.cursor.is_empty() {
37+
println!(
38+
"Next cursor: {}...",
39+
&events.cursor[..events.cursor.len().min(20)]
40+
);
3841
}
3942

4043
println!();
@@ -47,12 +50,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
4750

4851
for event in &open_events.events {
4952
println!(" {} | {}", event.event_ticker, event.title);
50-
if let Some(subtitle) = &event.sub_title {
51-
println!(" Subtitle: {}", subtitle);
53+
if !event.sub_title.is_empty() {
54+
println!(" Subtitle: {}", event.sub_title);
5255
}
5356
println!(" Series: {}", event.series_ticker);
54-
if let Some(category) = &event.category {
55-
println!(" Category: {}", category);
57+
if !event.category.is_empty() {
58+
println!(" Category: {}", event.category);
5659
}
5760
}
5861
println!();
@@ -72,9 +75,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7275
if let Some(markets) = &event.markets {
7376
println!(" Markets ({}):", markets.len());
7477
for market in markets.iter().take(5) {
75-
let title = market.title.as_deref().unwrap_or("(no title)");
76-
let yes_ask = market.yes_ask_dollars.as_deref().unwrap_or("N/A");
77-
println!(" {} | {} | YES ask: ${}", market.ticker, title, yes_ask);
78+
println!(
79+
" {} | {} | YES ask: ${}",
80+
market.ticker, market.title, market.yes_ask_dollars
81+
);
7882
}
7983
if markets.len() > 5 {
8084
println!(" ... and {} more markets", markets.len() - 5);
@@ -96,25 +100,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
96100
println!("Ticker: {}", event.event_ticker);
97101
println!("Title: {}", event.title);
98102
println!("Series: {}", event.series_ticker);
99-
if let Some(subtitle) = &event.sub_title {
100-
println!("Subtitle: {}", subtitle);
101-
}
102-
if let Some(category) = &event.category {
103-
println!("Category: {}", category);
103+
if !event.sub_title.is_empty() {
104+
println!("Subtitle: {}", event.sub_title);
104105
}
105-
if let Some(mutually_exclusive) = event.mutually_exclusive {
106-
println!("Mutually Exclusive: {}", mutually_exclusive);
106+
if !event.category.is_empty() {
107+
println!("Category: {}", event.category);
107108
}
109+
println!("Mutually Exclusive: {}", event.mutually_exclusive);
108110
if let Some(strike_date) = &event.strike_date {
109111
println!("Strike Date: {}", strike_date);
110112
}
111113
if let Some(strike_period) = &event.strike_period {
112114
println!("Strike Period: {}", strike_period);
113115
}
114116

115-
// Check for markets in the deprecated field
116-
if let Some(markets) = &event_response.markets {
117-
println!("Markets (deprecated field): {}", markets.len());
117+
// Check for markets in response
118+
if !event_response.markets.is_empty() {
119+
println!("Markets: {}", event_response.markets.len());
118120
}
119121
println!();
120122

@@ -131,20 +133,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
131133
);
132134
for market in markets {
133135
let status = format!("{:?}", market.status);
134-
let volume = market.volume.unwrap_or(0);
135-
let oi = market.open_interest.unwrap_or(0);
136-
137136
println!(
138137
" {} | {} | vol: {} | oi: {}",
139-
market.ticker, status, volume, oi
138+
market.ticker, status, market.volume, market.open_interest
140139
);
141140

142-
// Show pricing if available
143-
if let (Some(yes_bid), Some(yes_ask)) =
144-
(&market.yes_bid_dollars, &market.yes_ask_dollars)
145-
{
146-
println!(" YES: ${} bid / ${} ask", yes_bid, yes_ask);
147-
}
141+
// Show pricing
142+
println!(
143+
" YES: ${} bid / ${} ask",
144+
market.yes_bid_dollars, market.yes_ask_dollars
145+
);
148146
}
149147
}
150148
println!();
@@ -211,9 +209,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
211209

212210
println!("Page {}: fetched {} events", page, fetched);
213211

214-
if let Some(next_cursor) = response.cursor {
212+
if !response.cursor.is_empty() {
215213
if page < MAX_PAGES {
216-
cursor = Some(next_cursor);
214+
cursor = Some(response.cursor);
217215
} else {
218216
println!("Stopping after {} pages (demo limit)", MAX_PAGES);
219217
break;
@@ -298,8 +296,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
298296
market_candles.len()
299297
);
300298
}
301-
if let Some(adjusted) = candles.adjusted_end_ts {
302-
println!("Adjusted end timestamp: {}", adjusted);
299+
if candles.adjusted_end_ts != 0 {
300+
println!("Adjusted end timestamp: {}", candles.adjusted_end_ts);
303301
}
304302
println!();
305303

@@ -336,13 +334,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
336334
point.event_ticker, point.end_period_ts, point.period_interval
337335
);
338336
for pp in &point.percentile_points {
339-
let formatted = pp.formatted_forecast.as_deref().unwrap_or("-");
340-
let numerical = pp.numerical_forecast.map(|v| format!("{:.2}", v));
341337
println!(
342-
" {}th percentile: {} ({})",
338+
" {}th percentile: {} ({:.2})",
343339
pp.percentile / 100,
344-
formatted,
345-
numerical.as_deref().unwrap_or("-")
340+
pp.formatted_forecast,
341+
pp.numerical_forecast
346342
);
347343
}
348344
}

examples/exchange_status.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6363

6464
// Display each day's schedule from the first (current) period
6565
if let Some(period) = schedule.standard_hours.first() {
66-
if let Some(start) = &period.start_time {
67-
println!("Effective from: {}", start);
68-
}
69-
if let Some(end) = &period.end_time {
70-
println!("Until: {}", end);
71-
}
66+
println!("Effective from: {}", period.start_time);
67+
println!("Until: {}", period.end_time);
7268
println!();
7369

7470
print_day_sessions("Monday ", &period.monday);

examples/historical.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
3636
for market in &markets.markets {
3737
println!(
3838
" {} | {:?} | result: {:?} | vol: {}",
39-
market.ticker,
40-
market.status,
41-
market.result,
42-
market.volume.unwrap_or(0)
39+
market.ticker, market.status, market.result, market.volume
4340
);
4441
}
4542

46-
if let Some(cursor) = &markets.cursor {
47-
println!(" Next cursor: {}...", &cursor[..cursor.len().min(20)]);
43+
if !markets.cursor.is_empty() {
44+
println!(
45+
" Next cursor: {}...",
46+
&markets.cursor[..markets.cursor.len().min(20)]
47+
);
4848
}
4949
println!();
5050

@@ -56,7 +56,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
5656
let detail = client.get_historical_market(ticker).await?;
5757
let m = &detail.market;
5858

59-
println!(" Title: {}", m.title.as_deref().unwrap_or("(none)"));
59+
println!(" Title: {}", m.title);
6060
println!(" Event: {}", m.event_ticker);
6161
println!(" Type: {:?}", m.market_type);
6262
println!(" Status: {:?}", m.status);
@@ -79,11 +79,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7979
Ok(candles) => {
8080
println!(" {} candlesticks returned", candles.candlesticks.len());
8181
for candle in candles.candlesticks.iter().take(3) {
82-
let close = candle
83-
.price
84-
.as_ref()
85-
.and_then(|p| p.close.as_deref())
86-
.unwrap_or("N/A");
82+
let close = candle.price.close.as_deref().unwrap_or("N/A");
8783
println!(
8884
" ts={} close=${} vol={}",
8985
candle.end_period_ts, close, candle.volume

0 commit comments

Comments
 (0)