1use super::health::{DEFAULT_AMM_STEP_PCT, HealthThresholds, TOP10_LEVELS};
13use super::pair::Pair;
14use super::types::{BookSource, OrderBook, OrderBookLevel, PERCENT_PER_RATIO};
15
16pub const MAX_AMM_LEVELS: usize = 1000;
19
20pub const DEX_BOOK_QUOTE: &str = "USD";
23
24const ESTIMATE_HALF_SPREAD: f64 = 0.001;
26
27pub fn order_book_from_analytics(
32 _chain: &str,
33 pair: &crate::chains::DexPair,
34 symbol: &str,
35 thresholds: &HealthThresholds,
36) -> OrderBook {
37 let label = Pair {
38 base: symbol.to_string(),
39 quote: Some(DEX_BOOK_QUOTE.to_string()),
40 }
41 .to_string();
42 match pair.liquidity_base {
43 Some(x0) if x0.is_finite() && x0 > 0.0 && pair.price_usd > 0.0 => {
44 amm_book(label, pair.price_usd, x0, thresholds)
45 }
46 _ => estimate_book(label, pair.price_usd, pair.liquidity_usd),
47 }
48}
49
50fn amm_levels_per_side(step: f64, t: &HealthThresholds) -> usize {
53 let band = if t.peg_target > 0.0 {
54 t.peg_bands().outlier_width() / t.peg_target
55 } else {
56 0.0
57 };
58 let to_band = if band.is_finite() && band > 0.0 {
59 (band / step - 1e-9).ceil() as usize
60 } else {
61 0
62 };
63 to_band.clamp(TOP10_LEVELS, MAX_AMM_LEVELS)
64}
65
66fn amm_book(label: String, p0: f64, x0: f64, t: &HealthThresholds) -> OrderBook {
67 let step_pct = if t.amm_step_pct.is_finite() && t.amm_step_pct > 0.0 {
68 t.amm_step_pct
69 } else {
70 DEFAULT_AMM_STEP_PCT
71 };
72 let step = step_pct / PERCENT_PER_RATIO;
73 let n = amm_levels_per_side(step, t);
74 let base_at = |p: f64| x0 * (p0 / p).sqrt();
75
76 let mut bids = Vec::with_capacity(n);
77 let mut prev = x0;
78 for i in 1..=n {
79 let p = p0 * (1.0 - i as f64 * step);
80 if p <= 0.0 {
81 break;
82 }
83 let x = base_at(p);
84 bids.push(OrderBookLevel {
85 price: p,
86 quantity: x - prev,
87 });
88 prev = x;
89 }
90
91 let mut asks = Vec::with_capacity(n);
92 let mut prev = x0;
93 for i in 1..=n {
94 let p = p0 * (1.0 + i as f64 * step);
95 let x = base_at(p);
96 asks.push(OrderBookLevel {
97 price: p,
98 quantity: prev - x,
99 });
100 prev = x;
101 }
102
103 OrderBook {
104 pair: label,
105 source: BookSource::SyntheticAmm,
106 bids,
107 asks,
108 }
109}
110
111fn estimate_book(label: String, price: f64, liquidity: f64) -> OrderBook {
113 let bid_price = price * (1.0 - ESTIMATE_HALF_SPREAD);
114 let ask_price = price * (1.0 + ESTIMATE_HALF_SPREAD);
115 let half_liq = liquidity / 2.0;
116 let qty = |p: f64| if p > 0.0 { half_liq / p } else { 0.0 };
117 OrderBook {
118 pair: label,
119 source: BookSource::SyntheticEstimate,
120 bids: vec![OrderBookLevel {
121 price: bid_price,
122 quantity: qty(bid_price),
123 }],
124 asks: vec![OrderBookLevel {
125 price: ask_price,
126 quantity: qty(ask_price),
127 }],
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::chains::DexPair;
135
136 fn make_pair(price: f64, liquidity: f64) -> DexPair {
137 DexPair {
138 dex_name: "TestDex".into(),
139 pair_address: "0x0".into(),
140 base_token: "USDC".into(),
141 quote_token: "WETH".into(),
142 price_usd: price,
143 volume_24h: 0.0,
144 liquidity_usd: liquidity,
145 liquidity_base: None,
146 price_change_24h: 0.0,
147 buys_24h: 0,
148 sells_24h: 0,
149 buys_6h: 0,
150 sells_6h: 0,
151 buys_1h: 0,
152 sells_1h: 0,
153 pair_created_at: None,
154 url: None,
155 }
156 }
157
158 use crate::market::health::HealthThresholds;
159 use crate::market::types::BookSource;
160
161 fn amm_pair(price: f64, base_reserve: f64) -> DexPair {
162 DexPair {
163 liquidity_base: Some(base_reserve),
164 ..make_pair(price, 2.0 * price * base_reserve)
165 }
166 }
167
168 fn depth(levels: &[OrderBookLevel], n: usize) -> f64 {
169 levels.iter().take(n).map(OrderBookLevel::value).sum()
170 }
171
172 #[test]
173 fn test_amm_depth_within_one_percent_matches_constant_product() {
174 let (p0, x0) = (1.0, 1_000_000.0);
179 let book = order_book_from_analytics(
180 "ethereum",
181 &amm_pair(p0, x0),
182 "USDC",
183 &HealthThresholds::default(),
184 );
185 assert_eq!(book.source, BookSource::SyntheticAmm);
186 let bid_closed = x0 * p0 * (1.0 - 0.99_f64.sqrt());
187 let ask_closed = x0 * p0 * (1.01_f64.sqrt() - 1.0);
188 let (bid, ask) = (depth(&book.bids, 10), depth(&book.asks, 10));
189 assert!(
190 (bid - bid_closed).abs() / bid_closed < 0.005,
191 "bid {} vs {}",
192 bid,
193 bid_closed
194 );
195 assert!(
196 (ask - ask_closed).abs() / ask_closed < 0.005,
197 "ask {} vs {}",
198 ask,
199 ask_closed
200 );
201 assert!(book.bids.windows(2).all(|w| w[0].price > w[1].price));
203 assert!(book.asks.windows(2).all(|w| w[0].price < w[1].price));
204 assert!((book.bids[0].price - 0.999).abs() < 1e-12);
205 assert!((book.asks[0].price - 1.001).abs() < 1e-12);
206 }
207
208 #[test]
209 fn test_amm_level_count_covers_band_and_top10() {
210 let t = HealthThresholds::default();
213 let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &t);
214 assert_eq!((book.bids.len(), book.asks.len()), (10, 10));
215 let wide = HealthThresholds {
217 peg_range: 0.01,
218 ..t.clone()
219 };
220 let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &wide);
221 assert_eq!(book.asks.len(), 50);
222 let fine = HealthThresholds {
224 amm_step_pct: 0.05,
225 ..t
226 };
227 let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &fine);
228 assert_eq!(book.asks.len(), 10);
229 assert!((book.asks[0].price - 1.0005).abs() < 1e-12);
230 }
231
232 #[test]
233 fn test_amm_bad_step_falls_back_to_default() {
234 for step in [0.0, -1.0, f64::NAN, f64::INFINITY] {
237 let t = HealthThresholds {
238 amm_step_pct: step,
239 ..HealthThresholds::default()
240 };
241 let book = order_book_from_analytics("ethereum", &amm_pair(1.0, 1e6), "USDC", &t);
242 assert_eq!(book.asks.len(), 10, "step {}", step);
243 assert!((book.asks[0].price - 1.001).abs() < 1e-12, "step {}", step);
244 }
245 }
246
247 #[test]
248 fn test_missing_reserves_keep_the_single_level_estimate() {
249 let book = order_book_from_analytics(
251 "ethereum",
252 &make_pair(1.0, 100_000.0),
253 "USDC",
254 &HealthThresholds::default(),
255 );
256 assert_eq!(book.source, BookSource::SyntheticEstimate);
257 assert_eq!((book.bids.len(), book.asks.len()), (1, 1));
258 }
259
260 #[test]
261 fn test_order_book_from_analytics_normal() {
262 let pair = make_pair(1.0, 100_000.0);
263 let book =
264 order_book_from_analytics("ethereum", &pair, "USDC", &HealthThresholds::default());
265 assert_eq!(book.pair, "USDC/USD");
267 assert_eq!(book.bids.len(), 1);
268 assert_eq!(book.asks.len(), 1);
269 assert!(book.bids[0].price > 0.0);
270 assert!(book.asks[0].price > 0.0);
271 assert!(book.bids[0].quantity > 0.0);
272 assert!(book.asks[0].quantity > 0.0);
273 }
274
275 #[test]
276 fn test_order_book_from_analytics_zero_price() {
277 let pair = make_pair(0.0, 100_000.0);
280 let book =
281 order_book_from_analytics("ethereum", &pair, "TOKEN", &HealthThresholds::default());
282 assert_eq!(book.bids[0].quantity, 0.0);
283 assert_eq!(book.asks[0].quantity, 0.0);
284 }
285
286 #[test]
287 fn test_order_book_from_analytics_zero_liquidity() {
288 let pair = make_pair(1.0, 0.0);
289 let book = order_book_from_analytics("solana", &pair, "SOL", &HealthThresholds::default());
290 assert_eq!(book.bids[0].quantity, 0.0);
291 assert_eq!(book.asks[0].quantity, 0.0);
292 }
293}