1use anyhow::Result;
2use chrono::TimeZone;
3use rusqlite::{params, Connection};
4use std::collections::HashMap;
5
6use crate::binance::types::{BinanceAllOrder, BinanceMyTrade};
7
8#[derive(Debug, Clone)]
9pub struct PersistedTrade {
10 pub trade: BinanceMyTrade,
11 pub source: String,
12}
13
14#[derive(Debug, Clone)]
15pub struct DailyRealizedReturn {
16 pub symbol: String,
17 pub date: String,
18 pub realized_return_pct: f64,
19}
20
21#[derive(Debug, Clone)]
22pub struct StrategyScopedStats {
23 pub trade_count: u32,
24 pub win_count: u32,
25 pub lose_count: u32,
26 pub realized_pnl: f64,
27}
28
29fn ensure_trade_schema(conn: &Connection) -> Result<()> {
30 for ddl in [
31 "ALTER TABLE order_history_trades ADD COLUMN commission REAL NOT NULL DEFAULT 0.0",
32 "ALTER TABLE order_history_trades ADD COLUMN commission_asset TEXT NOT NULL DEFAULT ''",
33 "ALTER TABLE order_history_trades ADD COLUMN realized_pnl REAL NOT NULL DEFAULT 0.0",
34 "ALTER TABLE order_history_trades ADD COLUMN position_id TEXT",
35 "ALTER TABLE order_history_trades ADD COLUMN exit_reason_code TEXT",
36 "ALTER TABLE order_history_trades ADD COLUMN holding_ms INTEGER NOT NULL DEFAULT 0",
37 "ALTER TABLE order_history_trades ADD COLUMN mfe_usdt REAL NOT NULL DEFAULT 0.0",
38 "ALTER TABLE order_history_trades ADD COLUMN mae_usdt REAL NOT NULL DEFAULT 0.0",
39 "ALTER TABLE order_history_trades ADD COLUMN expected_return_usdt_at_entry REAL",
40 "ALTER TABLE order_history_trades ADD COLUMN p_win_estimate REAL",
41 "ALTER TABLE order_history_trades ADD COLUMN p_tail_loss_estimate REAL",
42 "ALTER TABLE order_history_trades ADD COLUMN p_timeout_exit_estimate REAL",
43 "ALTER TABLE order_history_trades ADD COLUMN prob_model_version TEXT",
44 "ALTER TABLE order_history_trades ADD COLUMN ev_model_version TEXT",
45 "ALTER TABLE order_history_trades ADD COLUMN confidence_level TEXT",
46 "ALTER TABLE order_history_trades ADD COLUMN n_eff REAL",
47 ] {
48 if let Err(e) = conn.execute(ddl, []) {
49 let msg = e.to_string();
50 if !msg.contains("duplicate column name") {
51 return Err(e.into());
52 }
53 }
54 }
55 conn.execute(
56 "CREATE INDEX IF NOT EXISTS idx_order_history_trades_position_id ON order_history_trades(position_id)",
57 [],
58 )?;
59 conn.execute(
60 "CREATE INDEX IF NOT EXISTS idx_order_history_trades_exit_reason ON order_history_trades(exit_reason_code)",
61 [],
62 )?;
63 Ok(())
64}
65
66fn ensure_strategy_stats_schema(conn: &Connection) -> Result<()> {
67 conn.execute_batch(
68 r#"
69 CREATE TABLE IF NOT EXISTS strategy_symbol_stats (
70 symbol TEXT NOT NULL,
71 source TEXT NOT NULL,
72 trade_count INTEGER NOT NULL,
73 win_count INTEGER NOT NULL,
74 lose_count INTEGER NOT NULL,
75 realized_pnl REAL NOT NULL,
76 updated_at_ms INTEGER NOT NULL,
77 PRIMARY KEY(symbol, source)
78 );
79 "#,
80 )?;
81 Ok(())
82}
83
84pub fn persist_strategy_symbol_stats(
85 symbol: &str,
86 stats: &HashMap<String, StrategyScopedStats>,
87) -> Result<()> {
88 std::fs::create_dir_all("data")?;
89 let mut conn = Connection::open("data/strategy_stats.sqlite")?;
90 ensure_strategy_stats_schema(&conn)?;
91 let tx = conn.transaction()?;
92 let now_ms = chrono::Utc::now().timestamp_millis();
93
94 for (source, row) in stats {
95 tx.execute(
96 r#"
97 INSERT INTO strategy_symbol_stats (
98 symbol, source, trade_count, win_count, lose_count, realized_pnl, updated_at_ms
99 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
100 ON CONFLICT(symbol, source) DO UPDATE SET
101 trade_count = excluded.trade_count,
102 win_count = excluded.win_count,
103 lose_count = excluded.lose_count,
104 realized_pnl = excluded.realized_pnl,
105 updated_at_ms = excluded.updated_at_ms
106 "#,
107 params![
108 symbol,
109 source,
110 row.trade_count as i64,
111 row.win_count as i64,
112 row.lose_count as i64,
113 row.realized_pnl,
114 now_ms,
115 ],
116 )?;
117 }
118 tx.commit()?;
119 Ok(())
120}
121
122pub fn load_strategy_symbol_stats(symbol: &str) -> Result<HashMap<String, StrategyScopedStats>> {
123 std::fs::create_dir_all("data")?;
124 let conn = Connection::open("data/strategy_stats.sqlite")?;
125 ensure_strategy_stats_schema(&conn)?;
126 let mut stmt = conn.prepare(
127 r#"
128 SELECT source, trade_count, win_count, lose_count, realized_pnl
129 FROM strategy_symbol_stats
130 WHERE symbol = ?1
131 "#,
132 )?;
133
134 let rows = stmt.query_map([symbol], |row| {
135 Ok((
136 row.get::<_, String>(0)?,
137 StrategyScopedStats {
138 trade_count: row.get::<_, i64>(1)?.max(0) as u32,
139 win_count: row.get::<_, i64>(2)?.max(0) as u32,
140 lose_count: row.get::<_, i64>(3)?.max(0) as u32,
141 realized_pnl: row.get::<_, f64>(4)?,
142 },
143 ))
144 })?;
145
146 let mut out = HashMap::new();
147 for row in rows {
148 let (source, stats) = row?;
149 out.insert(source, stats);
150 }
151 Ok(out)
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum HistoryBucket {
156 Day,
157 Hour,
158 Month,
159}
160
161pub fn persist_order_snapshot(
162 symbol: &str,
163 orders: &[BinanceAllOrder],
164 trades: &[BinanceMyTrade],
165) -> Result<()> {
166 std::fs::create_dir_all("data")?;
167 let mut conn = Connection::open("data/order_history.sqlite")?;
168 conn.execute_batch(
169 r#"
170 CREATE TABLE IF NOT EXISTS order_history_orders (
171 symbol TEXT NOT NULL,
172 order_id INTEGER NOT NULL,
173 client_order_id TEXT NOT NULL,
174 status TEXT NOT NULL,
175 side TEXT NOT NULL,
176 orig_qty REAL NOT NULL,
177 executed_qty REAL NOT NULL,
178 avg_price REAL NOT NULL,
179 event_time_ms INTEGER NOT NULL,
180 source TEXT NOT NULL,
181 updated_at_ms INTEGER NOT NULL,
182 PRIMARY KEY(symbol, order_id)
183 );
184
185 CREATE TABLE IF NOT EXISTS order_history_trades (
186 symbol TEXT NOT NULL,
187 trade_id INTEGER NOT NULL,
188 order_id INTEGER NOT NULL,
189 side TEXT NOT NULL,
190 qty REAL NOT NULL,
191 price REAL NOT NULL,
192 commission REAL NOT NULL DEFAULT 0.0,
193 commission_asset TEXT NOT NULL DEFAULT '',
194 event_time_ms INTEGER NOT NULL,
195 realized_pnl REAL NOT NULL DEFAULT 0.0,
196 source TEXT NOT NULL,
197 position_id TEXT,
198 exit_reason_code TEXT,
199 holding_ms INTEGER NOT NULL DEFAULT 0,
200 mfe_usdt REAL NOT NULL DEFAULT 0.0,
201 mae_usdt REAL NOT NULL DEFAULT 0.0,
202 expected_return_usdt_at_entry REAL,
203 p_win_estimate REAL,
204 p_tail_loss_estimate REAL,
205 p_timeout_exit_estimate REAL,
206 prob_model_version TEXT,
207 ev_model_version TEXT,
208 confidence_level TEXT,
209 n_eff REAL,
210 updated_at_ms INTEGER NOT NULL,
211 PRIMARY KEY(symbol, trade_id)
212 );
213
214 "#,
215 )?;
216 ensure_trade_schema(&conn)?;
217
218 let now_ms = chrono::Utc::now().timestamp_millis();
219 let tx = conn.transaction()?;
220 let mut source_by_order_id = std::collections::HashMap::new();
221
222 for o in orders {
223 let avg_price = if o.executed_qty > 0.0 {
224 o.cummulative_quote_qty / o.executed_qty
225 } else {
226 o.price
227 };
228 tx.execute(
229 r#"
230 INSERT INTO order_history_orders (
231 symbol, order_id, client_order_id, status, side,
232 orig_qty, executed_qty, avg_price, event_time_ms, source, updated_at_ms
233 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
234 ON CONFLICT(symbol, order_id) DO UPDATE SET
235 client_order_id = excluded.client_order_id,
236 status = excluded.status,
237 side = excluded.side,
238 orig_qty = excluded.orig_qty,
239 executed_qty = excluded.executed_qty,
240 avg_price = excluded.avg_price,
241 event_time_ms = excluded.event_time_ms,
242 source = excluded.source,
243 updated_at_ms = excluded.updated_at_ms
244 "#,
245 params![
246 symbol,
247 o.order_id as i64,
248 o.client_order_id,
249 o.status,
250 o.side,
251 o.orig_qty,
252 o.executed_qty,
253 avg_price,
254 o.update_time.max(o.time) as i64,
255 source_label_from_client_order_id(&o.client_order_id),
256 now_ms,
257 ],
258 )?;
259 source_by_order_id.insert(
260 o.order_id,
261 source_label_from_client_order_id(&o.client_order_id),
262 );
263 }
264
265 for t in trades {
266 tx.execute(
267 r#"
268 INSERT INTO order_history_trades (
269 symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl, source, updated_at_ms
270 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
271 ON CONFLICT(symbol, trade_id) DO UPDATE SET
272 order_id = excluded.order_id,
273 side = excluded.side,
274 qty = excluded.qty,
275 price = excluded.price,
276 commission = excluded.commission,
277 commission_asset = excluded.commission_asset,
278 event_time_ms = excluded.event_time_ms,
279 realized_pnl = excluded.realized_pnl,
280 source = excluded.source,
281 updated_at_ms = excluded.updated_at_ms
282 "#,
283 params![
284 symbol,
285 t.id as i64,
286 t.order_id as i64,
287 if t.is_buyer { "BUY" } else { "SELL" },
288 t.qty,
289 t.price,
290 t.commission,
291 t.commission_asset,
292 t.time as i64,
293 t.realized_pnl,
294 source_by_order_id
295 .get(&t.order_id)
296 .map(String::as_str)
297 .unwrap_or("UNKNOWN"),
298 now_ms,
299 ],
300 )?;
301 }
302
303 tx.commit()?;
304 Ok(())
305}
306
307fn source_label_from_client_order_id(client_order_id: &str) -> String {
308 if client_order_id.contains("-mnl-") {
309 "MANUAL".to_string()
310 } else if client_order_id.contains("-cfg-") {
311 "MA(Config)".to_string()
312 } else if client_order_id.contains("-fst-") {
313 "MA(Fast 5/20)".to_string()
314 } else if client_order_id.contains("-slw-") {
315 "MA(Slow 20/60)".to_string()
316 } else if let Some(source_tag) = parse_source_tag_from_client_order_id(client_order_id) {
317 source_tag.to_ascii_lowercase()
318 } else {
319 "UNKNOWN".to_string()
320 }
321}
322
323fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
324 let body = client_order_id.strip_prefix("sq-")?;
325 let (source_tag, _) = body.split_once('-')?;
326 if source_tag.is_empty() {
327 None
328 } else {
329 Some(source_tag)
330 }
331}
332
333pub fn load_persisted_trades(symbol: &str) -> Result<Vec<PersistedTrade>> {
334 std::fs::create_dir_all("data")?;
335 let conn = Connection::open("data/order_history.sqlite")?;
336 ensure_trade_schema(&conn)?;
337 let mut stmt = conn.prepare(
338 r#"
339 SELECT trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl, source
340 FROM order_history_trades
341 WHERE symbol = ?1
342 ORDER BY event_time_ms ASC, trade_id ASC
343 "#,
344 )?;
345
346 let rows = stmt.query_map([symbol], |row| {
347 let side: String = row.get(2)?;
348 let is_buyer = side.eq_ignore_ascii_case("BUY");
349 let trade = BinanceMyTrade {
350 symbol: symbol.to_string(),
351 id: row.get::<_, i64>(0)? as u64,
352 order_id: row.get::<_, i64>(1)? as u64,
353 price: row.get(4)?,
354 qty: row.get(3)?,
355 commission: row.get(5)?,
356 commission_asset: row.get(6)?,
357 time: row.get::<_, i64>(7)? as u64,
358 realized_pnl: row.get(8)?,
359 is_buyer,
360 is_maker: false,
361 };
362 Ok(PersistedTrade {
363 trade,
364 source: row.get(9)?,
365 })
366 })?;
367
368 let mut trades = Vec::new();
369 for row in rows {
370 trades.push(row?);
371 }
372 Ok(trades)
373}
374
375pub fn load_recent_persisted_trades_filtered(
376 symbol: Option<&str>,
377 source: Option<&str>,
378 limit: usize,
379) -> Result<Vec<PersistedTrade>> {
380 std::fs::create_dir_all("data")?;
381 let conn = Connection::open("data/order_history.sqlite")?;
382 ensure_trade_schema(&conn)?;
383
384 let limit = limit.max(1) as i64;
385 let (sql, bind_symbol, bind_source) = match (symbol, source) {
386 (Some(_), Some(_)) => (
387 r#"
388 SELECT symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl, source
389 FROM order_history_trades
390 WHERE symbol = ?1 AND LOWER(source) = LOWER(?2)
391 ORDER BY event_time_ms DESC, trade_id DESC
392 LIMIT ?3
393 "#,
394 true,
395 true,
396 ),
397 (Some(_), None) => (
398 r#"
399 SELECT symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl, source
400 FROM order_history_trades
401 WHERE symbol = ?1
402 ORDER BY event_time_ms DESC, trade_id DESC
403 LIMIT ?2
404 "#,
405 true,
406 false,
407 ),
408 (None, Some(_)) => (
409 r#"
410 SELECT symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl, source
411 FROM order_history_trades
412 WHERE LOWER(source) = LOWER(?1)
413 ORDER BY event_time_ms DESC, trade_id DESC
414 LIMIT ?2
415 "#,
416 false,
417 true,
418 ),
419 (None, None) => (
420 r#"
421 SELECT symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl, source
422 FROM order_history_trades
423 ORDER BY event_time_ms DESC, trade_id DESC
424 LIMIT ?1
425 "#,
426 false,
427 false,
428 ),
429 };
430
431 let mut stmt = conn.prepare(sql)?;
432 let mut out = Vec::new();
433 match (bind_symbol, bind_source) {
434 (true, true) => {
435 let rows = stmt.query_map(
436 params![
437 symbol.unwrap_or_default(),
438 source.unwrap_or_default(),
439 limit
440 ],
441 map_persisted_trade_row,
442 )?;
443 for row in rows {
444 out.push(row?);
445 }
446 }
447 (true, false) => {
448 let rows = stmt.query_map(
449 params![symbol.unwrap_or_default(), limit],
450 map_persisted_trade_row,
451 )?;
452 for row in rows {
453 out.push(row?);
454 }
455 }
456 (false, true) => {
457 let rows = stmt.query_map(
458 params![source.unwrap_or_default(), limit],
459 map_persisted_trade_row,
460 )?;
461 for row in rows {
462 out.push(row?);
463 }
464 }
465 (false, false) => {
466 let rows = stmt.query_map(params![limit], map_persisted_trade_row)?;
467 for row in rows {
468 out.push(row?);
469 }
470 }
471 }
472 Ok(out)
473}
474
475fn map_persisted_trade_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PersistedTrade> {
476 Ok(PersistedTrade {
477 trade: BinanceMyTrade {
478 symbol: row.get::<_, String>(0)?,
479 id: row.get::<_, i64>(1)? as u64,
480 order_id: row.get::<_, i64>(2)? as u64,
481 price: row.get(5)?,
482 qty: row.get(4)?,
483 commission: row.get(6)?,
484 commission_asset: row.get(7)?,
485 time: row.get::<_, i64>(8)? as u64,
486 realized_pnl: row.get(9)?,
487 is_buyer: row.get::<_, String>(3)?.eq_ignore_ascii_case("BUY"),
488 is_maker: false,
489 },
490 source: row.get(10)?,
491 })
492}
493
494pub fn load_last_trade_id(symbol: &str) -> Result<Option<u64>> {
495 std::fs::create_dir_all("data")?;
496 let conn = Connection::open("data/order_history.sqlite")?;
497 let mut stmt = conn.prepare(
498 r#"
499 SELECT MAX(trade_id)
500 FROM order_history_trades
501 WHERE symbol = ?1
502 "#,
503 )?;
504 let max_id = stmt.query_row([symbol], |row| row.get::<_, Option<i64>>(0))?;
505 Ok(max_id.map(|v| v as u64))
506}
507
508pub fn load_trade_count(symbol: &str) -> Result<usize> {
509 std::fs::create_dir_all("data")?;
510 let conn = Connection::open("data/order_history.sqlite")?;
511 let mut stmt = conn.prepare(
512 r#"
513 SELECT COUNT(*)
514 FROM order_history_trades
515 WHERE symbol = ?1
516 "#,
517 )?;
518 let count = stmt.query_row([symbol], |row| row.get::<_, i64>(0))?;
519 Ok(count.max(0) as usize)
520}
521
522#[derive(Clone, Copy, Default)]
523struct LongPos {
524 qty: f64,
525 cost_quote: f64,
526}
527
528#[derive(Clone, Copy, Default)]
529struct DailyBucket {
530 pnl: f64,
531 basis: f64,
532}
533
534pub fn load_realized_returns_by_bucket(
535 bucket: HistoryBucket,
536 limit: usize,
537) -> Result<Vec<DailyRealizedReturn>> {
538 std::fs::create_dir_all("data")?;
539 let conn = Connection::open("data/order_history.sqlite")?;
540 ensure_trade_schema(&conn)?;
541 let mut stmt = conn.prepare(
542 r#"
543 SELECT symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl
544 FROM order_history_trades
545 ORDER BY symbol ASC, event_time_ms ASC, trade_id ASC
546 "#,
547 )?;
548
549 let rows = stmt.query_map([], |row| {
550 Ok((
551 row.get::<_, String>(0)?,
552 row.get::<_, i64>(1)? as u64,
553 row.get::<_, i64>(2)? as u64,
554 row.get::<_, String>(3)?,
555 row.get::<_, f64>(4)?,
556 row.get::<_, f64>(5)?,
557 row.get::<_, f64>(6)?,
558 row.get::<_, String>(7)?,
559 row.get::<_, i64>(8)? as u64,
560 row.get::<_, f64>(9)?,
561 ))
562 })?;
563
564 let mut pos_by_symbol: HashMap<String, LongPos> = HashMap::new();
565 let mut daily_by_key: HashMap<(String, String), DailyBucket> = HashMap::new();
566
567 for row in rows {
568 let (
569 symbol,
570 _trade_id,
571 _order_id,
572 side,
573 qty_raw,
574 price,
575 commission,
576 commission_asset,
577 event_time_ms,
578 realized_pnl,
579 ) = row?;
580 let qty = qty_raw.max(0.0);
581 if qty <= f64::EPSILON {
582 continue;
583 }
584
585 let (base_asset, quote_asset) = split_symbol_assets(&symbol);
586 let fee_is_base =
587 !base_asset.is_empty() && commission_asset.eq_ignore_ascii_case(&base_asset);
588 let fee_is_quote =
589 !quote_asset.is_empty() && commission_asset.eq_ignore_ascii_case("e_asset);
590 let pos = pos_by_symbol.entry(symbol.clone()).or_default();
591
592 let date = chrono::Utc
593 .timestamp_millis_opt(event_time_ms as i64)
594 .single()
595 .map(|dt| dt.with_timezone(&chrono::Local))
596 .map(|dt| match bucket {
597 HistoryBucket::Day => dt.format("%Y-%m-%d").to_string(),
598 HistoryBucket::Hour => dt.format("%Y-%m-%d %H:00").to_string(),
599 HistoryBucket::Month => dt.format("%Y-%m").to_string(),
600 })
601 .unwrap_or_else(|| "unknown".to_string());
602
603 if symbol.ends_with("#FUT") {
605 let basis = (qty * price).abs();
606 let bucket = daily_by_key.entry((symbol.clone(), date)).or_default();
607 bucket.pnl += realized_pnl;
608 bucket.basis += basis;
609 continue;
610 }
611
612 if side.eq_ignore_ascii_case("BUY") {
613 let net_qty = (qty
614 - if fee_is_base {
615 commission.max(0.0)
616 } else {
617 0.0
618 })
619 .max(0.0);
620 if net_qty <= f64::EPSILON {
621 continue;
622 }
623 let fee_quote = if fee_is_quote {
624 commission.max(0.0)
625 } else {
626 0.0
627 };
628 pos.qty += net_qty;
629 pos.cost_quote += qty * price + fee_quote;
630 continue;
631 }
632
633 if pos.qty <= f64::EPSILON {
634 continue;
635 }
636 let close_qty = qty.min(pos.qty);
637 if close_qty <= f64::EPSILON {
638 continue;
639 }
640 let avg_cost = pos.cost_quote / pos.qty.max(f64::EPSILON);
641 let fee_quote_total = if fee_is_quote {
642 commission.max(0.0)
643 } else if fee_is_base {
644 commission.max(0.0) * price
645 } else {
646 0.0
647 };
648 let fee_quote = fee_quote_total * (close_qty / qty.max(f64::EPSILON));
649 let realized_pnl = (close_qty * price - fee_quote) - (avg_cost * close_qty);
650 let realized_basis = avg_cost * close_qty;
651
652 let bucket = daily_by_key.entry((symbol.clone(), date)).or_default();
653 bucket.pnl += realized_pnl;
654 bucket.basis += realized_basis;
655
656 pos.qty -= close_qty;
657 pos.cost_quote -= realized_basis;
658 if pos.qty <= f64::EPSILON {
659 pos.qty = 0.0;
660 pos.cost_quote = 0.0;
661 }
662 }
663
664 let mut out: Vec<DailyRealizedReturn> = daily_by_key
665 .into_iter()
666 .map(|((symbol, date), b)| DailyRealizedReturn {
667 symbol,
668 date,
669 realized_return_pct: if b.basis.abs() > f64::EPSILON {
670 (b.pnl / b.basis) * 100.0
671 } else {
672 0.0
673 },
674 })
675 .collect();
676
677 out.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.symbol.cmp(&b.symbol)));
678 if out.len() > limit {
679 out.truncate(limit);
680 }
681 Ok(out)
682}
683
684pub fn load_daily_realized_returns(limit: usize) -> Result<Vec<DailyRealizedReturn>> {
685 load_realized_returns_by_bucket(HistoryBucket::Day, limit)
686}
687
688fn split_symbol_assets(symbol: &str) -> (String, String) {
689 const QUOTE_SUFFIXES: [&str; 10] = [
690 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
691 ];
692 for q in QUOTE_SUFFIXES {
693 if let Some(base) = symbol.strip_suffix(q) {
694 if !base.is_empty() {
695 return (base.to_string(), q.to_string());
696 }
697 }
698 }
699 (symbol.to_string(), String::new())
700}