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![symbol.unwrap_or_default(), source.unwrap_or_default(), limit],
437 map_persisted_trade_row,
438 )?;
439 for row in rows {
440 out.push(row?);
441 }
442 }
443 (true, false) => {
444 let rows = stmt.query_map(
445 params![symbol.unwrap_or_default(), limit],
446 map_persisted_trade_row,
447 )?;
448 for row in rows {
449 out.push(row?);
450 }
451 }
452 (false, true) => {
453 let rows = stmt.query_map(
454 params![source.unwrap_or_default(), limit],
455 map_persisted_trade_row,
456 )?;
457 for row in rows {
458 out.push(row?);
459 }
460 }
461 (false, false) => {
462 let rows = stmt.query_map(params![limit], map_persisted_trade_row)?;
463 for row in rows {
464 out.push(row?);
465 }
466 }
467 }
468 Ok(out)
469}
470
471fn map_persisted_trade_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PersistedTrade> {
472 Ok(PersistedTrade {
473 trade: BinanceMyTrade {
474 symbol: row.get::<_, String>(0)?,
475 id: row.get::<_, i64>(1)? as u64,
476 order_id: row.get::<_, i64>(2)? as u64,
477 price: row.get(5)?,
478 qty: row.get(4)?,
479 commission: row.get(6)?,
480 commission_asset: row.get(7)?,
481 time: row.get::<_, i64>(8)? as u64,
482 realized_pnl: row.get(9)?,
483 is_buyer: row.get::<_, String>(3)?.eq_ignore_ascii_case("BUY"),
484 is_maker: false,
485 },
486 source: row.get(10)?,
487 })
488}
489
490pub fn load_last_trade_id(symbol: &str) -> Result<Option<u64>> {
491 std::fs::create_dir_all("data")?;
492 let conn = Connection::open("data/order_history.sqlite")?;
493 let mut stmt = conn.prepare(
494 r#"
495 SELECT MAX(trade_id)
496 FROM order_history_trades
497 WHERE symbol = ?1
498 "#,
499 )?;
500 let max_id = stmt.query_row([symbol], |row| row.get::<_, Option<i64>>(0))?;
501 Ok(max_id.map(|v| v as u64))
502}
503
504pub fn load_trade_count(symbol: &str) -> Result<usize> {
505 std::fs::create_dir_all("data")?;
506 let conn = Connection::open("data/order_history.sqlite")?;
507 let mut stmt = conn.prepare(
508 r#"
509 SELECT COUNT(*)
510 FROM order_history_trades
511 WHERE symbol = ?1
512 "#,
513 )?;
514 let count = stmt.query_row([symbol], |row| row.get::<_, i64>(0))?;
515 Ok(count.max(0) as usize)
516}
517
518#[derive(Clone, Copy, Default)]
519struct LongPos {
520 qty: f64,
521 cost_quote: f64,
522}
523
524#[derive(Clone, Copy, Default)]
525struct DailyBucket {
526 pnl: f64,
527 basis: f64,
528}
529
530pub fn load_realized_returns_by_bucket(
531 bucket: HistoryBucket,
532 limit: usize,
533) -> Result<Vec<DailyRealizedReturn>> {
534 std::fs::create_dir_all("data")?;
535 let conn = Connection::open("data/order_history.sqlite")?;
536 ensure_trade_schema(&conn)?;
537 let mut stmt = conn.prepare(
538 r#"
539 SELECT symbol, trade_id, order_id, side, qty, price, commission, commission_asset, event_time_ms, realized_pnl
540 FROM order_history_trades
541 ORDER BY symbol ASC, event_time_ms ASC, trade_id ASC
542 "#,
543 )?;
544
545 let rows = stmt.query_map([], |row| {
546 Ok((
547 row.get::<_, String>(0)?,
548 row.get::<_, i64>(1)? as u64,
549 row.get::<_, i64>(2)? as u64,
550 row.get::<_, String>(3)?,
551 row.get::<_, f64>(4)?,
552 row.get::<_, f64>(5)?,
553 row.get::<_, f64>(6)?,
554 row.get::<_, String>(7)?,
555 row.get::<_, i64>(8)? as u64,
556 row.get::<_, f64>(9)?,
557 ))
558 })?;
559
560 let mut pos_by_symbol: HashMap<String, LongPos> = HashMap::new();
561 let mut daily_by_key: HashMap<(String, String), DailyBucket> = HashMap::new();
562
563 for row in rows {
564 let (
565 symbol,
566 _trade_id,
567 _order_id,
568 side,
569 qty_raw,
570 price,
571 commission,
572 commission_asset,
573 event_time_ms,
574 realized_pnl,
575 ) = row?;
576 let qty = qty_raw.max(0.0);
577 if qty <= f64::EPSILON {
578 continue;
579 }
580
581 let (base_asset, quote_asset) = split_symbol_assets(&symbol);
582 let fee_is_base =
583 !base_asset.is_empty() && commission_asset.eq_ignore_ascii_case(&base_asset);
584 let fee_is_quote =
585 !quote_asset.is_empty() && commission_asset.eq_ignore_ascii_case("e_asset);
586 let pos = pos_by_symbol.entry(symbol.clone()).or_default();
587
588 let date = chrono::Utc
589 .timestamp_millis_opt(event_time_ms as i64)
590 .single()
591 .map(|dt| dt.with_timezone(&chrono::Local))
592 .map(|dt| match bucket {
593 HistoryBucket::Day => dt.format("%Y-%m-%d").to_string(),
594 HistoryBucket::Hour => dt.format("%Y-%m-%d %H:00").to_string(),
595 HistoryBucket::Month => dt.format("%Y-%m").to_string(),
596 })
597 .unwrap_or_else(|| "unknown".to_string());
598
599 if symbol.ends_with("#FUT") {
601 let basis = (qty * price).abs();
602 let bucket = daily_by_key.entry((symbol.clone(), date)).or_default();
603 bucket.pnl += realized_pnl;
604 bucket.basis += basis;
605 continue;
606 }
607
608 if side.eq_ignore_ascii_case("BUY") {
609 let net_qty = (qty
610 - if fee_is_base {
611 commission.max(0.0)
612 } else {
613 0.0
614 })
615 .max(0.0);
616 if net_qty <= f64::EPSILON {
617 continue;
618 }
619 let fee_quote = if fee_is_quote {
620 commission.max(0.0)
621 } else {
622 0.0
623 };
624 pos.qty += net_qty;
625 pos.cost_quote += qty * price + fee_quote;
626 continue;
627 }
628
629 if pos.qty <= f64::EPSILON {
630 continue;
631 }
632 let close_qty = qty.min(pos.qty);
633 if close_qty <= f64::EPSILON {
634 continue;
635 }
636 let avg_cost = pos.cost_quote / pos.qty.max(f64::EPSILON);
637 let fee_quote_total = if fee_is_quote {
638 commission.max(0.0)
639 } else if fee_is_base {
640 commission.max(0.0) * price
641 } else {
642 0.0
643 };
644 let fee_quote = fee_quote_total * (close_qty / qty.max(f64::EPSILON));
645 let realized_pnl = (close_qty * price - fee_quote) - (avg_cost * close_qty);
646 let realized_basis = avg_cost * close_qty;
647
648 let bucket = daily_by_key.entry((symbol.clone(), date)).or_default();
649 bucket.pnl += realized_pnl;
650 bucket.basis += realized_basis;
651
652 pos.qty -= close_qty;
653 pos.cost_quote -= realized_basis;
654 if pos.qty <= f64::EPSILON {
655 pos.qty = 0.0;
656 pos.cost_quote = 0.0;
657 }
658 }
659
660 let mut out: Vec<DailyRealizedReturn> = daily_by_key
661 .into_iter()
662 .map(|((symbol, date), b)| DailyRealizedReturn {
663 symbol,
664 date,
665 realized_return_pct: if b.basis.abs() > f64::EPSILON {
666 (b.pnl / b.basis) * 100.0
667 } else {
668 0.0
669 },
670 })
671 .collect();
672
673 out.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.symbol.cmp(&b.symbol)));
674 if out.len() > limit {
675 out.truncate(limit);
676 }
677 Ok(out)
678}
679
680pub fn load_daily_realized_returns(limit: usize) -> Result<Vec<DailyRealizedReturn>> {
681 load_realized_returns_by_bucket(HistoryBucket::Day, limit)
682}
683
684fn split_symbol_assets(symbol: &str) -> (String, String) {
685 const QUOTE_SUFFIXES: [&str; 10] = [
686 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
687 ];
688 for q in QUOTE_SUFFIXES {
689 if let Some(base) = symbol.strip_suffix(q) {
690 if !base.is_empty() {
691 return (base.to_string(), q.to_string());
692 }
693 }
694 }
695 (symbol.to_string(), String::new())
696}