fin_primitives/arbitrage/
mod.rs1use std::collections::HashMap;
13
14#[derive(Debug, Clone, PartialEq)]
18pub struct ArbitrageOpportunity {
19 pub id: String,
21 pub asset: String,
23 pub buy_market: String,
25 pub sell_market: String,
27 pub buy_price: f64,
29 pub sell_price: f64,
31 pub spread_bps: f64,
33 pub estimated_profit_usd: f64,
35 pub confidence: f64,
37 pub detected_at: u64,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ArbSignal {
46 Enter,
48 Exit,
50 Hold,
52}
53
54#[derive(Debug, Clone, PartialEq)]
70pub struct TriangularArb {
71 pub currency_a: String,
73 pub currency_b: String,
75 pub currency_c: String,
77 pub rate_ab: f64,
79 pub rate_bc: f64,
81 pub rate_ca: f64,
83 pub profit_pct: f64,
85}
86
87impl TriangularArb {
88 pub fn detect(rate_ab: f64, rate_bc: f64, rate_ca: f64) -> Option<TriangularArb> {
92 let product = rate_ab * rate_bc * rate_ca;
93 if product > 1.0 {
94 Some(TriangularArb {
95 currency_a: "A".to_string(),
96 currency_b: "B".to_string(),
97 currency_c: "C".to_string(),
98 rate_ab,
99 rate_bc,
100 rate_ca,
101 profit_pct: (product - 1.0) * 100.0,
102 })
103 } else {
104 None
105 }
106 }
107
108 pub fn profit_pct(&self) -> f64 {
110 (self.rate_ab * self.rate_bc * self.rate_ca - 1.0) * 100.0
111 }
112}
113
114#[derive(Debug, Clone, PartialEq)]
118pub struct StatisticalArb {
119 pub symbol_a: String,
121 pub symbol_b: String,
123 pub hedge_ratio: f64,
125 pub spread: f64,
127 pub z_score: f64,
129 pub signal: ArbSignal,
131}
132
133pub struct ArbitrageScanner;
154
155impl ArbitrageScanner {
156 pub fn scan_cross_market(
162 markets: &HashMap<String, HashMap<String, f64>>,
163 ) -> Vec<ArbitrageOpportunity> {
164 let mut opportunities = Vec::new();
165
166 let mut asset_prices: HashMap<&str, Vec<(&str, f64)>> = HashMap::new();
168 for (market, symbols) in markets {
169 for (symbol, &price) in symbols {
170 asset_prices
171 .entry(symbol.as_str())
172 .or_default()
173 .push((market.as_str(), price));
174 }
175 }
176
177 for (asset, price_list) in &asset_prices {
179 if price_list.len() < 2 {
180 continue;
181 }
182
183 let mut min_market = price_list[0].0;
185 let mut min_price = price_list[0].1;
186 let mut max_market = price_list[0].0;
187 let mut max_price = price_list[0].1;
188
189 for &(market, price) in price_list {
190 if price < min_price {
191 min_price = price;
192 min_market = market;
193 }
194 if price > max_price {
195 max_price = price;
196 max_market = market;
197 }
198 }
199
200 if min_price <= 0.0 || min_market == max_market {
201 continue;
202 }
203
204 let spread_bps = (max_price - min_price) / min_price * 10_000.0;
205 let estimated_profit_usd = max_price - min_price;
206 let confidence = (spread_bps / 100.0).min(1.0);
208
209 opportunities.push(ArbitrageOpportunity {
210 id: format!("{}-{}-{}", asset, min_market, max_market),
211 asset: (*asset).to_string(),
212 buy_market: min_market.to_string(),
213 sell_market: max_market.to_string(),
214 buy_price: min_price,
215 sell_price: max_price,
216 spread_bps,
217 estimated_profit_usd,
218 confidence,
219 detected_at: 0,
220 });
221 }
222
223 opportunities
224 }
225
226 pub fn scan_triangular(rates: &HashMap<String, f64>) -> Vec<TriangularArb> {
231 let mut currencies: Vec<String> = Vec::new();
233 for key in rates.keys() {
234 let parts: Vec<&str> = key.split('/').collect();
235 if parts.len() == 2 {
236 let a = parts[0].to_string();
237 let b = parts[1].to_string();
238 if !currencies.contains(&a) {
239 currencies.push(a);
240 }
241 if !currencies.contains(&b) {
242 currencies.push(b);
243 }
244 }
245 }
246
247 let mut results = Vec::new();
248 let n = currencies.len();
249
250 for i in 0..n {
252 for j in 0..n {
253 if j == i {
254 continue;
255 }
256 for k in 0..n {
257 if k == i || k == j {
258 continue;
259 }
260 let ca = ¤cies[i];
261 let cb = ¤cies[j];
262 let cc = ¤cies[k];
263
264 let key_ab = format!("{}/{}", ca, cb);
265 let key_bc = format!("{}/{}", cb, cc);
266 let key_ca = format!("{}/{}", cc, ca);
267
268 if let (Some(&rate_ab), Some(&rate_bc), Some(&rate_ca)) = (
269 rates.get(&key_ab),
270 rates.get(&key_bc),
271 rates.get(&key_ca),
272 ) {
273 let product = rate_ab * rate_bc * rate_ca;
274 if product > 1.0 {
275 results.push(TriangularArb {
276 currency_a: ca.clone(),
277 currency_b: cb.clone(),
278 currency_c: cc.clone(),
279 rate_ab,
280 rate_bc,
281 rate_ca,
282 profit_pct: (product - 1.0) * 100.0,
283 });
284 }
285 }
286 }
287 }
288 }
289
290 results
291 }
292
293 pub fn filter_by_min_profit(
295 opps: Vec<ArbitrageOpportunity>,
296 min_bps: f64,
297 ) -> Vec<ArbitrageOpportunity> {
298 opps.into_iter()
299 .filter(|o| o.spread_bps >= min_bps)
300 .collect()
301 }
302
303 pub fn rank_by_confidence(opps: &mut [ArbitrageOpportunity]) {
305 opps.sort_by(|a, b| {
306 b.confidence
307 .partial_cmp(&a.confidence)
308 .unwrap_or(std::cmp::Ordering::Equal)
309 });
310 }
311}
312
313#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn markets() -> HashMap<String, HashMap<String, f64>> {
320 let mut m: HashMap<String, HashMap<String, f64>> = HashMap::new();
321 let mut nyse = HashMap::new();
322 nyse.insert("AAPL".to_string(), 150.00_f64);
323 nyse.insert("GOOG".to_string(), 2800.00_f64);
324 m.insert("NYSE".to_string(), nyse);
325
326 let mut nasdaq = HashMap::new();
327 nasdaq.insert("AAPL".to_string(), 150.30_f64);
328 nasdaq.insert("GOOG".to_string(), 2800.00_f64);
329 m.insert("NASDAQ".to_string(), nasdaq);
330
331 m
332 }
333
334 #[test]
335 fn test_triangular_arb_detect_profitable() {
336 let arb = TriangularArb::detect(1.10, 1.20, 0.80);
338 assert!(arb.is_some());
339 let arb = arb.unwrap();
340 assert!(arb.profit_pct() > 0.0);
341 let expected = (1.10 * 1.20 * 0.80 - 1.0) * 100.0;
342 assert!((arb.profit_pct() - expected).abs() < 1e-9);
343 }
344
345 #[test]
346 fn test_triangular_arb_detect_not_profitable() {
347 let arb = TriangularArb::detect(1.0, 1.0, 0.9);
349 assert!(arb.is_none());
350 }
351
352 #[test]
353 fn test_triangular_arb_profit_pct_formula() {
354 let arb = TriangularArb {
355 currency_a: "USD".to_string(),
356 currency_b: "EUR".to_string(),
357 currency_c: "GBP".to_string(),
358 rate_ab: 1.1,
359 rate_bc: 1.15,
360 rate_ca: 0.80,
361 profit_pct: 0.0,
362 };
363 let expected = (1.1 * 1.15 * 0.80 - 1.0) * 100.0;
364 assert!((arb.profit_pct() - expected).abs() < 1e-9);
365 }
366
367 #[test]
368 fn test_scan_cross_market_finds_aapl() {
369 let markets = markets();
370 let opps = ArbitrageScanner::scan_cross_market(&markets);
371 assert!(!opps.is_empty());
372 let aapl_opp = opps.iter().find(|o| o.asset == "AAPL");
373 assert!(aapl_opp.is_some());
374 let o = aapl_opp.unwrap();
375 assert_eq!(o.buy_market, "NYSE");
376 assert_eq!(o.sell_market, "NASDAQ");
377 assert!((o.buy_price - 150.00).abs() < 1e-9);
378 assert!((o.sell_price - 150.30).abs() < 1e-9);
379 assert!((o.spread_bps - 20.0).abs() < 1e-6);
381 }
382
383 #[test]
384 fn test_scan_cross_market_no_arb_same_price() {
385 let markets = markets();
386 let opps = ArbitrageScanner::scan_cross_market(&markets);
387 let goog_opp = opps.iter().find(|o| o.asset == "GOOG");
389 assert!(goog_opp.is_none());
390 }
391
392 #[test]
393 fn test_filter_by_min_profit() {
394 let markets = markets();
395 let opps = ArbitrageScanner::scan_cross_market(&markets);
396 let filtered = ArbitrageScanner::filter_by_min_profit(opps, 50.0);
398 assert!(filtered.is_empty());
399 }
400
401 #[test]
402 fn test_rank_by_confidence() {
403 let mut opps = vec![
404 ArbitrageOpportunity {
405 id: "1".to_string(),
406 asset: "A".to_string(),
407 buy_market: "M1".to_string(),
408 sell_market: "M2".to_string(),
409 buy_price: 100.0,
410 sell_price: 101.0,
411 spread_bps: 100.0,
412 estimated_profit_usd: 1.0,
413 confidence: 0.3,
414 detected_at: 0,
415 },
416 ArbitrageOpportunity {
417 id: "2".to_string(),
418 asset: "B".to_string(),
419 buy_market: "M1".to_string(),
420 sell_market: "M2".to_string(),
421 buy_price: 100.0,
422 sell_price: 102.0,
423 spread_bps: 200.0,
424 estimated_profit_usd: 2.0,
425 confidence: 0.9,
426 detected_at: 0,
427 },
428 ];
429 ArbitrageScanner::rank_by_confidence(&mut opps);
430 assert_eq!(opps[0].id, "2");
431 assert_eq!(opps[1].id, "1");
432 }
433
434 #[test]
435 fn test_scan_triangular() {
436 let mut rates = HashMap::new();
437 rates.insert("USD/EUR".to_string(), 0.91);
439 rates.insert("EUR/GBP".to_string(), 0.86);
440 rates.insert("GBP/USD".to_string(), 1.30); let results = ArbitrageScanner::scan_triangular(&rates);
442 assert!(!results.is_empty());
444 for r in &results {
445 assert!(r.profit_pct() > 0.0);
446 }
447 }
448
449 #[test]
450 fn test_scan_triangular_no_arb() {
451 let mut rates = HashMap::new();
452 rates.insert("USD/EUR".to_string(), 0.91);
453 rates.insert("EUR/GBP".to_string(), 0.86);
454 rates.insert("GBP/USD".to_string(), 1.10); let results = ArbitrageScanner::scan_triangular(&rates);
456 assert!(results.is_empty());
457 }
458
459 #[test]
460 fn test_statistical_arb_fields() {
461 let sa = StatisticalArb {
462 symbol_a: "SPY".to_string(),
463 symbol_b: "IVV".to_string(),
464 hedge_ratio: 1.02,
465 spread: 0.5,
466 z_score: 2.1,
467 signal: ArbSignal::Enter,
468 };
469 assert_eq!(sa.signal, ArbSignal::Enter);
470 assert!((sa.z_score - 2.1).abs() < 1e-9);
471 }
472}