Skip to main content

sandbox_quant/
order_store.rs

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(&quote_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        // Futures: realized_pnl is provided directly by exchange, do not apply spot long inventory logic.
600        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}