@ebowwa/quant-rust 0.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.
Files changed (60) hide show
  1. package/README.md +161 -0
  2. package/bun-ffi.d.ts +54 -0
  3. package/dist/index.js +576 -0
  4. package/dist/src/index.d.ts +324 -0
  5. package/dist/src/index.d.ts.map +1 -0
  6. package/dist/types/index.d.ts +403 -0
  7. package/dist/types/index.d.ts.map +1 -0
  8. package/native/README.md +62 -0
  9. package/native/darwin-arm64/libquant_rust.dylib +0 -0
  10. package/package.json +70 -0
  11. package/scripts/postinstall.cjs +85 -0
  12. package/src/ffi.rs +496 -0
  13. package/src/index.ts +1073 -0
  14. package/src/indicators/ma.rs +222 -0
  15. package/src/indicators/mod.rs +18 -0
  16. package/src/indicators/momentum.rs +353 -0
  17. package/src/indicators/sr.rs +195 -0
  18. package/src/indicators/trend.rs +351 -0
  19. package/src/indicators/volatility.rs +270 -0
  20. package/src/indicators/volume.rs +213 -0
  21. package/src/lib.rs +130 -0
  22. package/src/patterns/breakout.rs +431 -0
  23. package/src/patterns/chart.rs +772 -0
  24. package/src/patterns/mod.rs +394 -0
  25. package/src/patterns/sr.rs +423 -0
  26. package/src/prediction/amm.rs +338 -0
  27. package/src/prediction/arbitrage.rs +230 -0
  28. package/src/prediction/calibration.rs +317 -0
  29. package/src/prediction/kelly.rs +232 -0
  30. package/src/prediction/lmsr.rs +194 -0
  31. package/src/prediction/mod.rs +59 -0
  32. package/src/prediction/odds.rs +229 -0
  33. package/src/prediction/pnl.rs +254 -0
  34. package/src/prediction/risk.rs +228 -0
  35. package/src/risk/beta.rs +257 -0
  36. package/src/risk/drawdown.rs +256 -0
  37. package/src/risk/leverage.rs +201 -0
  38. package/src/risk/mod.rs +388 -0
  39. package/src/risk/portfolio.rs +287 -0
  40. package/src/risk/ratios.rs +290 -0
  41. package/src/risk/sizing.rs +194 -0
  42. package/src/risk/var.rs +222 -0
  43. package/src/stats/cdf.rs +257 -0
  44. package/src/stats/correlation.rs +225 -0
  45. package/src/stats/distribution.rs +194 -0
  46. package/src/stats/hypothesis.rs +177 -0
  47. package/src/stats/matrix.rs +346 -0
  48. package/src/stats/mod.rs +257 -0
  49. package/src/stats/regression.rs +239 -0
  50. package/src/stats/rolling.rs +193 -0
  51. package/src/stats/timeseries.rs +263 -0
  52. package/src/types.rs +224 -0
  53. package/src/utils/mod.rs +215 -0
  54. package/src/utils/normalize.rs +192 -0
  55. package/src/utils/price.rs +167 -0
  56. package/src/utils/quantiles.rs +177 -0
  57. package/src/utils/returns.rs +158 -0
  58. package/src/utils/rolling.rs +97 -0
  59. package/src/utils/stats.rs +154 -0
  60. package/types/index.ts +513 -0
@@ -0,0 +1,338 @@
1
+ //! # AMM Calculations (Constant Product Market Maker)
2
+ //!
3
+ //! Calculations for constant product AMMs (Uniswap V2 style) used in prediction markets.
4
+
5
+ use crate::prediction::Outcome;
6
+
7
+ /// State of a constant product AMM
8
+ #[derive(Debug, Clone, Copy)]
9
+ pub struct AMMState {
10
+ /// Shares in YES pool
11
+ pub pool_yes: f64,
12
+ /// Shares in NO pool
13
+ pub pool_no: f64,
14
+ /// Constant product (pool_yes * pool_no)
15
+ pub k: f64,
16
+ /// Total LP tokens outstanding
17
+ pub lp_token_supply: f64,
18
+ /// Fee in basis points
19
+ pub fee: f64,
20
+ }
21
+
22
+ impl AMMState {
23
+ /// Create a new AMM state
24
+ pub fn new(pool_yes: f64, pool_no: f64, lp_token_supply: f64, fee: f64) -> Self {
25
+ let k = pool_yes * pool_no;
26
+ Self {
27
+ pool_yes,
28
+ pool_no,
29
+ k,
30
+ lp_token_supply,
31
+ fee,
32
+ }
33
+ }
34
+ }
35
+
36
+ /// Result of price impact calculation
37
+ #[derive(Debug, Clone, Copy)]
38
+ pub struct PriceImpactResult {
39
+ /// Current price before trade
40
+ pub current_price: f64,
41
+ /// Price after trade execution
42
+ pub new_price: f64,
43
+ /// Average price paid per share
44
+ pub avg_price: f64,
45
+ /// Price impact as percentage
46
+ pub price_impact: f64,
47
+ /// Slippage from current price
48
+ pub slippage: f64,
49
+ }
50
+
51
+ /// Calculate price after buying shares (constant product AMM).
52
+ ///
53
+ /// Uses xy = k invariant. When buying YES, the YES price increases.
54
+ ///
55
+ /// # Arguments
56
+ /// * `state` - Current AMM state
57
+ /// * `outcome` - Which outcome to buy (Yes or No)
58
+ /// * `shares` - Number of shares to buy
59
+ ///
60
+ /// # Returns
61
+ /// Price after the trade
62
+ ///
63
+ /// # Example
64
+ /// ```
65
+ /// use quant_rust::prediction::amm::{AMMState, amm_price_after_buy};
66
+ /// use quant_rust::prediction::Outcome;
67
+ /// let state = AMMState::new(1000.0, 1000.0, 1000.0, 0.0);
68
+ /// let price = amm_price_after_buy(&state, Outcome::Yes, 100.0);
69
+ /// assert!(price > 0.5); // Price increases after buying YES
70
+ /// ```
71
+ pub fn amm_price_after_buy(state: &AMMState, outcome: Outcome, shares: f64) -> f64 {
72
+ let AMMState { pool_yes, pool_no, .. } = *state;
73
+
74
+ match outcome {
75
+ Outcome::Yes => {
76
+ // Buying YES: poolNo increases (you're betting against NO)
77
+ // The pool balances shift to reflect higher YES probability
78
+ let new_pool_no = pool_no + shares * (pool_no / pool_yes);
79
+ new_pool_no / (pool_yes + new_pool_no)
80
+ }
81
+ Outcome::No => {
82
+ // Buying NO: poolYes increases
83
+ let new_pool_yes = pool_yes + shares * (pool_yes / pool_no);
84
+ new_pool_yes / (new_pool_yes + pool_no)
85
+ }
86
+ }
87
+ }
88
+
89
+ /// Calculate cost to buy shares (constant product AMM).
90
+ ///
91
+ /// # Arguments
92
+ /// * `state` - Current AMM state
93
+ /// * `outcome` - Which outcome to buy
94
+ /// * `shares` - Number of shares to buy
95
+ ///
96
+ /// # Returns
97
+ /// Cost in quote currency
98
+ ///
99
+ /// # Example
100
+ /// ```
101
+ /// use quant_rust::prediction::amm::{AMMState, amm_buy_cost};
102
+ /// use quant_rust::prediction::Outcome;
103
+ /// let state = AMMState::new(1000.0, 1000.0, 1000.0, 0.0);
104
+ /// let cost = amm_buy_cost(&state, Outcome::Yes, 100.0);
105
+ /// assert!(cost > 0.0);
106
+ /// ```
107
+ pub fn amm_buy_cost(state: &AMMState, outcome: Outcome, shares: f64) -> f64 {
108
+ let AMMState { pool_yes, pool_no, k, .. } = *state;
109
+
110
+ match outcome {
111
+ Outcome::Yes => {
112
+ // Buying YES shares
113
+ // After: newPoolYes = poolYes + shares
114
+ // newPoolNo = k / (poolYes + shares)
115
+ let new_pool_no = k / (pool_yes + shares);
116
+ pool_no - new_pool_no
117
+ }
118
+ Outcome::No => {
119
+ // Buying NO shares
120
+ let new_pool_yes = k / (pool_no + shares);
121
+ pool_yes - new_pool_yes
122
+ }
123
+ }
124
+ }
125
+
126
+ /// Calculate shares received for a given cost.
127
+ ///
128
+ /// # Arguments
129
+ /// * `state` - Current AMM state
130
+ /// * `outcome` - Which outcome to buy
131
+ /// * `cost` - Amount to spend in quote currency
132
+ ///
133
+ /// # Returns
134
+ /// Number of shares received
135
+ ///
136
+ /// # Example
137
+ /// ```
138
+ /// use quant_rust::prediction::amm::{AMMState, amm_shares_received};
139
+ /// use quant_rust::prediction::Outcome;
140
+ /// let state = AMMState::new(1000.0, 1000.0, 1000.0, 0.0);
141
+ /// let shares = amm_shares_received(&state, Outcome::Yes, 50.0);
142
+ /// assert!(shares > 0.0);
143
+ /// ```
144
+ pub fn amm_shares_received(state: &AMMState, outcome: Outcome, cost: f64) -> f64 {
145
+ let AMMState { pool_yes, pool_no, k, .. } = *state;
146
+
147
+ match outcome {
148
+ Outcome::Yes => {
149
+ // Spending from NO pool side
150
+ let new_pool_no = pool_no - cost;
151
+ let new_pool_yes = k / new_pool_no;
152
+ new_pool_yes - pool_yes
153
+ }
154
+ Outcome::No => {
155
+ let new_pool_yes = pool_yes - cost;
156
+ let new_pool_no = k / new_pool_yes;
157
+ new_pool_no - pool_no
158
+ }
159
+ }
160
+ }
161
+
162
+ /// Calculate price impact of a trade.
163
+ ///
164
+ /// # Arguments
165
+ /// * `state` - Current AMM state
166
+ /// * `outcome` - Which outcome to buy
167
+ /// * `shares` - Number of shares to buy
168
+ ///
169
+ /// # Returns
170
+ /// PriceImpactResult with detailed metrics
171
+ ///
172
+ /// # Example
173
+ /// ```
174
+ /// use quant_rust::prediction::amm::{AMMState, amm_price_impact};
175
+ /// use quant_rust::prediction::Outcome;
176
+ /// let state = AMMState::new(1000.0, 1000.0, 1000.0, 0.0);
177
+ /// let impact = amm_price_impact(&state, Outcome::Yes, 100.0);
178
+ /// assert!(impact.price_impact > 0.0);
179
+ /// ```
180
+ pub fn amm_price_impact(state: &AMMState, outcome: Outcome, shares: f64) -> PriceImpactResult {
181
+ let AMMState { pool_yes, pool_no, .. } = *state;
182
+
183
+ // Current price
184
+ let current_price = match outcome {
185
+ Outcome::Yes => pool_no / (pool_yes + pool_no),
186
+ Outcome::No => pool_yes / (pool_yes + pool_no),
187
+ };
188
+
189
+ let new_price = amm_price_after_buy(state, outcome, shares);
190
+ let cost = amm_buy_cost(state, outcome, shares);
191
+ let avg_price = if shares > 0.0 { cost / shares } else { 0.0 };
192
+
193
+ let price_impact = if current_price > 0.0 {
194
+ (new_price - current_price).abs() / current_price
195
+ } else {
196
+ 0.0
197
+ };
198
+
199
+ let slippage = if current_price > 0.0 {
200
+ (avg_price - current_price).abs() / current_price
201
+ } else {
202
+ 0.0
203
+ };
204
+
205
+ PriceImpactResult {
206
+ current_price,
207
+ new_price,
208
+ avg_price,
209
+ price_impact,
210
+ slippage,
211
+ }
212
+ }
213
+
214
+ /// Calculate impermanent loss for LP.
215
+ ///
216
+ /// Impermanent loss is the loss incurred by providing liquidity compared to
217
+ /// simply holding the assets.
218
+ ///
219
+ /// # Arguments
220
+ /// * `initial_price` - Initial price (as probability 0-1)
221
+ /// * `current_price` - Current price
222
+ ///
223
+ /// # Returns
224
+ /// Impermanent loss as a decimal (e.g., 0.05 = 5% loss)
225
+ ///
226
+ /// # Example
227
+ /// ```
228
+ /// use quant_rust::prediction::amm::amm_impermanent_loss;
229
+ /// let il = amm_impermanent_loss(0.5, 0.6);
230
+ /// assert!(il > 0.0);
231
+ /// ```
232
+ pub fn amm_impermanent_loss(initial_price: f64, current_price: f64) -> f64 {
233
+ if initial_price <= 0.0 || current_price <= 0.0 {
234
+ return 0.0;
235
+ }
236
+
237
+ let price_ratio = current_price / initial_price;
238
+
239
+ // IL = 2 * sqrt(price_ratio) / (1 + price_ratio) - 1
240
+ let il = (2.0 * price_ratio.sqrt()) / (1.0 + price_ratio) - 1.0;
241
+ il.abs()
242
+ }
243
+
244
+ #[cfg(test)]
245
+ mod tests {
246
+ use super::*;
247
+
248
+ fn create_balanced_pool() -> AMMState {
249
+ AMMState::new(1000.0, 1000.0, 1000.0, 0.0)
250
+ }
251
+
252
+ #[test]
253
+ fn test_amm_state_creation() {
254
+ let state = create_balanced_pool();
255
+ assert_eq!(state.pool_yes, 1000.0);
256
+ assert_eq!(state.pool_no, 1000.0);
257
+ assert_eq!(state.k, 1000000.0);
258
+ }
259
+
260
+ #[test]
261
+ fn test_amm_buy_cost_yes() {
262
+ let state = create_balanced_pool();
263
+ let cost = amm_buy_cost(&state, Outcome::Yes, 100.0);
264
+ // Cost should be less than shares at current price (0.5)
265
+ // because we get a better rate initially
266
+ assert!(cost > 0.0);
267
+ assert!(cost < 100.0); // Less than 100 * 0.5 would be at current price
268
+ }
269
+
270
+ #[test]
271
+ fn test_amm_buy_cost_no() {
272
+ let state = create_balanced_pool();
273
+ let cost = amm_buy_cost(&state, Outcome::No, 100.0);
274
+ assert!(cost > 0.0);
275
+ }
276
+
277
+ #[test]
278
+ fn test_amm_price_after_buy() {
279
+ let state = create_balanced_pool();
280
+ let price_before = 0.5;
281
+ let price_after = amm_price_after_buy(&state, Outcome::Yes, 100.0);
282
+ // Buying YES should increase YES price
283
+ assert!(price_after > price_before);
284
+ }
285
+
286
+ #[test]
287
+ fn test_amm_shares_received() {
288
+ let state = create_balanced_pool();
289
+ let cost = 50.0;
290
+ let shares = amm_shares_received(&state, Outcome::Yes, cost);
291
+ assert!(shares > 0.0);
292
+ // Verify round-trip: shares received for cost should equal cost to buy shares
293
+ let cost_check = amm_buy_cost(&state, Outcome::Yes, shares);
294
+ assert!((cost - cost_check).abs() < 1e-6);
295
+ }
296
+
297
+ #[test]
298
+ fn test_amm_price_impact() {
299
+ let state = create_balanced_pool();
300
+ let impact = amm_price_impact(&state, Outcome::Yes, 100.0);
301
+ assert_eq!(impact.current_price, 0.5);
302
+ assert!(impact.new_price > 0.5);
303
+ assert!(impact.price_impact > 0.0);
304
+ assert!(impact.slippage >= 0.0);
305
+ }
306
+
307
+ #[test]
308
+ fn test_amm_impermanent_loss_no_change() {
309
+ let il = amm_impermanent_loss(0.5, 0.5);
310
+ assert!((il - 0.0).abs() < 1e-10);
311
+ }
312
+
313
+ #[test]
314
+ fn test_amm_impermanent_loss_with_change() {
315
+ let il = amm_impermanent_loss(0.5, 0.6);
316
+ assert!(il > 0.0);
317
+ assert!(il < 0.1); // Should be relatively small for this price change
318
+ }
319
+
320
+ #[test]
321
+ fn test_amm_impermanent_loss_symmetry() {
322
+ // IL is symmetric in terms of price RATIOS that are inverses
323
+ // price_ratio = 0.7/0.5 = 1.4 and price_ratio = 0.5/0.7 ≈ 0.714
324
+ // These give the same IL (using reciprocal ratios)
325
+ let il_up = amm_impermanent_loss(0.5, 0.7);
326
+ let il_down = amm_impermanent_loss(0.7, 0.5);
327
+ // IL should be the same for inverse price moves
328
+ assert!((il_up - il_down).abs() < 1e-10);
329
+ }
330
+
331
+ #[test]
332
+ fn test_large_trade_high_impact() {
333
+ let state = create_balanced_pool();
334
+ // Buying 50% of pool should cause significant impact
335
+ let impact = amm_price_impact(&state, Outcome::Yes, 500.0);
336
+ assert!(impact.price_impact > 0.1); // More than 10% impact
337
+ }
338
+ }
@@ -0,0 +1,230 @@
1
+ //! # Arbitrage Detection
2
+ //!
3
+ //! Detect arbitrage opportunities in prediction markets.
4
+
5
+ /// A single arbitrage opportunity
6
+ #[derive(Debug, Clone, Copy)]
7
+ pub struct ArbitrageOpportunity {
8
+ /// Price of YES shares
9
+ pub yes_price: f64,
10
+ /// Price of NO shares
11
+ pub no_price: f64,
12
+ /// Profit per $1 invested
13
+ pub profit: f64,
14
+ /// Amount to invest in YES
15
+ pub yes_size: f64,
16
+ /// Amount to invest in NO
17
+ pub no_size: f64,
18
+ }
19
+
20
+ /// Market price information for cross-market arbitrage
21
+ #[derive(Debug, Clone)]
22
+ pub struct MarketPrice {
23
+ /// Market name/identifier
24
+ pub name: String,
25
+ /// YES price
26
+ pub yes_price: f64,
27
+ /// NO price
28
+ pub no_price: f64,
29
+ }
30
+
31
+ /// Cross-market arbitrage opportunity
32
+ #[derive(Debug, Clone)]
33
+ pub struct CrossMarketArbitrage {
34
+ /// Market to buy YES from
35
+ pub yes_market: String,
36
+ /// Market to buy NO from
37
+ pub no_market: String,
38
+ /// Price of YES
39
+ pub yes_price: f64,
40
+ /// Price of NO
41
+ pub no_price: f64,
42
+ /// Total cost (yes_price + no_price)
43
+ pub total_cost: f64,
44
+ /// Profit per $1 invested
45
+ pub profit: f64,
46
+ }
47
+
48
+ /// Detect arbitrage opportunity (YES + NO prices < 1).
49
+ ///
50
+ /// If YES price + NO price < 1, you can buy both and guarantee a profit.
51
+ ///
52
+ /// # Arguments
53
+ /// * `yes_price` - Price of YES shares (0-1)
54
+ /// * `no_price` - Price of NO shares (0-1)
55
+ ///
56
+ /// # Returns
57
+ /// Some(ArbitrageOpportunity) if arbitrage exists, None otherwise
58
+ ///
59
+ /// # Example
60
+ /// ```
61
+ /// use quant_rust::prediction::arbitrage::detect_arbitrage;
62
+ /// let arb = detect_arbitrage(0.45, 0.45);
63
+ /// assert!(arb.is_some());
64
+ /// assert!((arb.unwrap().profit - 0.10).abs() < 1e-10);
65
+ /// ```
66
+ pub fn detect_arbitrage(yes_price: f64, no_price: f64) -> Option<ArbitrageOpportunity> {
67
+ let total = yes_price + no_price;
68
+
69
+ if total >= 1.0 {
70
+ return None;
71
+ }
72
+
73
+ // Buy equal amounts of both, guaranteed $1 back
74
+ let profit = 1.0 - total;
75
+
76
+ // For $1 investment: buy such that we get same payout either way
77
+ // If we spend $x on YES at price p_yes and $y on NO at price p_no
78
+ // x + y = 1 and x/p_yes = y/p_no (equal shares)
79
+ // x = p_yes / (p_yes + p_no), y = p_no / (p_yes + p_no)
80
+ let yes_size = yes_price / total;
81
+ let no_size = no_price / total;
82
+
83
+ Some(ArbitrageOpportunity {
84
+ yes_price,
85
+ no_price,
86
+ profit,
87
+ yes_size,
88
+ no_size,
89
+ })
90
+ }
91
+
92
+ /// Find best arbitrage across multiple markets on the same question.
93
+ ///
94
+ /// Looks for cheapest YES and cheapest NO across all markets.
95
+ ///
96
+ /// # Arguments
97
+ /// * `markets` - Slice of market prices
98
+ ///
99
+ /// # Returns
100
+ /// Some(CrossMarketArbitrage) if arbitrage exists, None otherwise
101
+ ///
102
+ /// # Example
103
+ /// ```
104
+ /// use quant_rust::prediction::arbitrage::{find_cross_market_arbitrage, MarketPrice};
105
+ /// let markets = vec![
106
+ /// MarketPrice { name: "market_a".to_string(), yes_price: 0.50, no_price: 0.55 },
107
+ /// MarketPrice { name: "market_b".to_string(), yes_price: 0.45, no_price: 0.60 },
108
+ /// ];
109
+ /// let arb = find_cross_market_arbitrage(&markets);
110
+ /// // Cheapest YES (0.45) + cheapest NO (0.55) = 1.0, no arb
111
+ /// assert!(arb.is_none());
112
+ /// ```
113
+ pub fn find_cross_market_arbitrage(markets: &[MarketPrice]) -> Option<CrossMarketArbitrage> {
114
+ if markets.is_empty() {
115
+ return None;
116
+ }
117
+
118
+ // Find cheapest YES and cheapest NO across markets
119
+ let mut cheapest_yes = Option::<(&MarketPrice, f64)>::None;
120
+ let mut cheapest_no = Option::<(&MarketPrice, f64)>::None;
121
+
122
+ for market in markets {
123
+ if cheapest_yes.is_none() || market.yes_price < cheapest_yes.unwrap().1 {
124
+ cheapest_yes = Some((market, market.yes_price));
125
+ }
126
+ if cheapest_no.is_none() || market.no_price < cheapest_no.unwrap().1 {
127
+ cheapest_no = Some((market, market.no_price));
128
+ }
129
+ }
130
+
131
+ let (yes_market, yes_price) = cheapest_yes?;
132
+ let (no_market, no_price) = cheapest_no?;
133
+
134
+ // Check if combined < 1
135
+ let total_cost = yes_price + no_price;
136
+
137
+ if total_cost >= 1.0 {
138
+ return None;
139
+ }
140
+
141
+ let profit = 1.0 - total_cost;
142
+
143
+ Some(CrossMarketArbitrage {
144
+ yes_market: yes_market.name.clone(),
145
+ no_market: no_market.name.clone(),
146
+ yes_price,
147
+ no_price,
148
+ total_cost,
149
+ profit,
150
+ })
151
+ }
152
+
153
+ #[cfg(test)]
154
+ mod tests {
155
+ use super::*;
156
+
157
+ #[test]
158
+ fn test_detect_arbitrage_exists() {
159
+ let arb = detect_arbitrage(0.45, 0.45);
160
+ assert!(arb.is_some());
161
+ let arb = arb.unwrap();
162
+ assert!((arb.profit - 0.10).abs() < 1e-10);
163
+ }
164
+
165
+ #[test]
166
+ fn test_detect_arbitrage_none() {
167
+ let arb = detect_arbitrage(0.55, 0.55);
168
+ assert!(arb.is_none());
169
+ }
170
+
171
+ #[test]
172
+ fn test_detect_arbitrage_exactly_one() {
173
+ let arb = detect_arbitrage(0.50, 0.50);
174
+ assert!(arb.is_none()); // No profit if exactly 1.0
175
+ }
176
+
177
+ #[test]
178
+ fn test_detect_arbitrage_sizes() {
179
+ let arb = detect_arbitrage(0.40, 0.50).unwrap();
180
+ // Total = 0.90, so yes_size = 0.40/0.90, no_size = 0.50/0.90
181
+ assert!((arb.yes_size - 0.4444).abs() < 0.01);
182
+ assert!((arb.no_size - 0.5556).abs() < 0.01);
183
+ assert!((arb.yes_size + arb.no_size - 1.0).abs() < 1e-10);
184
+ }
185
+
186
+ #[test]
187
+ fn test_find_cross_market_arbitrage_exists() {
188
+ let markets = vec![
189
+ MarketPrice { name: "a".to_string(), yes_price: 0.40, no_price: 0.65 },
190
+ MarketPrice { name: "b".to_string(), yes_price: 0.55, no_price: 0.45 },
191
+ ];
192
+ let arb = find_cross_market_arbitrage(&markets);
193
+ assert!(arb.is_some());
194
+ let arb = arb.unwrap();
195
+ // Cheapest: YES=0.40 (a), NO=0.45 (b), total=0.85
196
+ assert_eq!(arb.yes_market, "a");
197
+ assert_eq!(arb.no_market, "b");
198
+ assert!((arb.profit - 0.15).abs() < 1e-10);
199
+ }
200
+
201
+ #[test]
202
+ fn test_find_cross_market_arbitrage_none() {
203
+ let markets = vec![
204
+ MarketPrice { name: "a".to_string(), yes_price: 0.55, no_price: 0.55 },
205
+ MarketPrice { name: "b".to_string(), yes_price: 0.60, no_price: 0.50 },
206
+ ];
207
+ let arb = find_cross_market_arbitrage(&markets);
208
+ assert!(arb.is_none()); // Cheapest 0.55 + 0.50 = 1.05 > 1
209
+ }
210
+
211
+ #[test]
212
+ fn test_find_cross_market_empty() {
213
+ let markets: Vec<MarketPrice> = vec![];
214
+ let arb = find_cross_market_arbitrage(&markets);
215
+ assert!(arb.is_none());
216
+ }
217
+
218
+ #[test]
219
+ fn test_find_cross_market_same_market() {
220
+ // Arb can exist within a single market
221
+ let markets = vec![
222
+ MarketPrice { name: "a".to_string(), yes_price: 0.45, no_price: 0.45 },
223
+ ];
224
+ let arb = find_cross_market_arbitrage(&markets);
225
+ assert!(arb.is_some());
226
+ let arb = arb.unwrap();
227
+ assert_eq!(arb.yes_market, "a");
228
+ assert_eq!(arb.no_market, "a");
229
+ }
230
+ }