Skip to main content

fin_primitives/arbitrage/
mod.rs

1//! Cross-market arbitrage detection: ArbitrageOpportunity, TriangularArb, StatisticalArb,
2//! ArbitrageScanner (scan_cross_market, scan_triangular, filter_by_min_profit, rank_by_confidence).
3//!
4//! ## Responsibility
5//! Cross-market and triangular arbitrage detection for financial markets.
6//!
7//! ## Guarantees
8//! - Zero panics; all computations are pure functions returning `Option` or `Vec`
9//! - `f64` is used intentionally for statistical computations (not prices)
10//! - All spread values are in basis points (bps = 0.01%)
11
12use std::collections::HashMap;
13
14// ─── ArbitrageOpportunity ─────────────────────────────────────────────────────
15
16/// A detected cross-market arbitrage opportunity for a single asset.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ArbitrageOpportunity {
19    /// Unique identifier for this opportunity.
20    pub id: String,
21    /// The asset/symbol being arbitraged.
22    pub asset: String,
23    /// The market to buy from (lower price).
24    pub buy_market: String,
25    /// The market to sell into (higher price).
26    pub sell_market: String,
27    /// Buy-side price.
28    pub buy_price: f64,
29    /// Sell-side price.
30    pub sell_price: f64,
31    /// Spread in basis points: (sell - buy) / buy * 10_000.
32    pub spread_bps: f64,
33    /// Estimated profit in USD (assumes 1 unit traded).
34    pub estimated_profit_usd: f64,
35    /// Confidence score in [0.0, 1.0].
36    pub confidence: f64,
37    /// Unix timestamp (ms) when detected.
38    pub detected_at: u64,
39}
40
41// ─── ArbSignal ────────────────────────────────────────────────────────────────
42
43/// Signal for statistical arbitrage positions.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ArbSignal {
46    /// Enter a new position.
47    Enter,
48    /// Exit the current position.
49    Exit,
50    /// Hold — no action.
51    Hold,
52}
53
54// ─── TriangularArb ────────────────────────────────────────────────────────────
55
56/// A detected triangular arbitrage opportunity across three currencies.
57///
58/// # Example
59/// ```rust
60/// use fin_primitives::arbitrage::TriangularArb;
61///
62/// // USD -> EUR -> GBP -> USD cycle
63/// let arb = TriangularArb::detect(1.10, 1.15, 0.80);
64/// // If rate_ab * rate_bc * rate_ca > 1 there is profit
65/// if let Some(t) = arb {
66///     assert!(t.profit_pct() > 0.0);
67/// }
68/// ```
69#[derive(Debug, Clone, PartialEq)]
70pub struct TriangularArb {
71    /// Base currency A.
72    pub currency_a: String,
73    /// Intermediate currency B.
74    pub currency_b: String,
75    /// Return currency C.
76    pub currency_c: String,
77    /// Exchange rate A -> B.
78    pub rate_ab: f64,
79    /// Exchange rate B -> C.
80    pub rate_bc: f64,
81    /// Exchange rate C -> A.
82    pub rate_ca: f64,
83    /// Profit percentage: (rate_ab * rate_bc * rate_ca - 1) * 100.
84    pub profit_pct: f64,
85}
86
87impl TriangularArb {
88    /// Detect a triangular arbitrage given three cross rates.
89    ///
90    /// Returns `Some` only when `rate_ab * rate_bc * rate_ca > 1.0` (i.e. profit > 0).
91    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    /// Profit percentage of this cycle: (rate_ab * rate_bc * rate_ca - 1.0) * 100.
109    pub fn profit_pct(&self) -> f64 {
110        (self.rate_ab * self.rate_bc * self.rate_ca - 1.0) * 100.0
111    }
112}
113
114// ─── StatisticalArb ───────────────────────────────────────────────────────────
115
116/// Statistical arbitrage pair with z-score based signalling.
117#[derive(Debug, Clone, PartialEq)]
118pub struct StatisticalArb {
119    /// First leg symbol.
120    pub symbol_a: String,
121    /// Second leg symbol.
122    pub symbol_b: String,
123    /// Hedge ratio (units of B per unit of A).
124    pub hedge_ratio: f64,
125    /// Current spread value: price_a - hedge_ratio * price_b.
126    pub spread: f64,
127    /// Z-score of the current spread relative to its historical distribution.
128    pub z_score: f64,
129    /// Trading signal derived from the z-score.
130    pub signal: ArbSignal,
131}
132
133// ─── ArbitrageScanner ─────────────────────────────────────────────────────────
134
135/// Scans market data for cross-market and triangular arbitrage opportunities.
136///
137/// # Example
138/// ```rust
139/// use fin_primitives::arbitrage::ArbitrageScanner;
140/// use std::collections::HashMap;
141///
142/// let mut markets: HashMap<String, HashMap<String, f64>> = HashMap::new();
143/// let mut nyse = HashMap::new();
144/// nyse.insert("AAPL".to_string(), 150.00_f64);
145/// markets.insert("NYSE".to_string(), nyse);
146/// let mut nasdaq = HashMap::new();
147/// nasdaq.insert("AAPL".to_string(), 150.50_f64);
148/// markets.insert("NASDAQ".to_string(), nasdaq);
149///
150/// let opps = ArbitrageScanner::scan_cross_market(&markets);
151/// assert!(!opps.is_empty());
152/// ```
153pub struct ArbitrageScanner;
154
155impl ArbitrageScanner {
156    /// Find same-asset price discrepancies across markets.
157    ///
158    /// `markets` maps market name -> (symbol -> price).
159    /// Returns one `ArbitrageOpportunity` per (asset, buy_market, sell_market) triple
160    /// where a positive spread exists.
161    pub fn scan_cross_market(
162        markets: &HashMap<String, HashMap<String, f64>>,
163    ) -> Vec<ArbitrageOpportunity> {
164        let mut opportunities = Vec::new();
165
166        // Collect all asset prices across markets
167        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 each asset, find the cheapest and most expensive market
178        for (asset, price_list) in &asset_prices {
179            if price_list.len() < 2 {
180                continue;
181            }
182
183            // Find min and max price markets
184            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            // Confidence grows with spread but saturates at 1.0
207            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    /// Scan for triangular arbitrage given a map of "A/B" -> rate.
227    ///
228    /// Enumerates all currency triples present in the rate map and returns
229    /// those with a product > 1.0 (profitable cycle).
230    pub fn scan_triangular(rates: &HashMap<String, f64>) -> Vec<TriangularArb> {
231        // Parse all currencies from pair keys
232        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        // Enumerate all triples (i, j, k)
251        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 = &currencies[i];
261                    let cb = &currencies[j];
262                    let cc = &currencies[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    /// Filter opportunities to only those with spread >= `min_bps`.
294    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    /// Sort opportunities descending by confidence score (in-place).
304    pub fn rank_by_confidence(opps: &mut Vec<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// ─── Tests ────────────────────────────────────────────────────────────────────
314
315#[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        // 1.10 * 1.20 * 0.80 = 1.056 > 1 -> profitable
337        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        // 1.0 * 1.0 * 0.9 = 0.9 < 1 -> no arbitrage
348        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        // spread_bps = 0.30 / 150.00 * 10000 = 20 bps
380        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        // GOOG has same price on both exchanges, no arb
388        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        // AAPL spread is 20 bps; filter to > 50 bps should remove it
397        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        // USD -> EUR -> GBP -> USD cycle with profit
438        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); // product: 0.91*0.86*1.30 = 1.01738 > 1
441        let results = ArbitrageScanner::scan_triangular(&rates);
442        // Should find at least one profitable triple
443        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); // product: 0.91*0.86*1.10 = 0.860 < 1
455        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}