Repository navigation
Expand file tree
/
Copy pathsqlite.rs
More file actions
3738 lines (3494 loc) · 168 KB
/
Copy pathsqlite.rs
File metadata and controls
3738 lines (3494 loc) · 168 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
use anyhow::{Context, Result};
use rust_decimal::Decimal;
use rusqlite::{Connection, params};
use std::str::FromStr;
use uuid::Uuid;
use crate::types::PositionState;
pub use super::types::*;
/// Normalize Decimal to string: strip trailing zeros so all exchanges produce
/// consistent representations (e.g. "18.0" → "18", "0.5300" → "0.53").
#[inline]
fn dec(d: Decimal) -> String {
d.normalize().to_string()
}
/// Derived PnL breakdown for a closed trade — computed from per-order fill
/// data + trade-level funding. Single source of truth for any UI/stats that
/// show realized PnL.
///
/// Returns `(gross_pnl, net_pnl, net_pnl_pct)` where:
/// gross = (exit_1 - entry_1) × qty + (entry_2 - exit_2) × qty (is_long_1)
/// net = gross - fees + funding_total
/// pct = net / avg_notional × 100
///
/// For partial/failed closes (any price zero), gross and pct are zero —
/// matching the old executor's "unhedged close" fallback.
fn derive_pnl(
is_long_1: bool,
qty: &str,
entry_1: &str, entry_2: &str,
exit_1: &str, exit_2: &str,
fees: &str,
funding_1: &str, funding_2: &str,
) -> (Decimal, Decimal, Decimal) {
use rust_decimal_macros::dec as decm;
// Trim before parse — SQLite's CAST ... AS REAL tolerates leading
// whitespace, so callers that previously went through raw SQL might
// hand us " 99" etc. Strict `Decimal::from_str` would silently
// collapse those to zero and trigger the `both_filled` fallback.
let q = Decimal::from_str(qty.trim()).unwrap_or(Decimal::ZERO);
let e1 = Decimal::from_str(entry_1.trim()).unwrap_or(Decimal::ZERO);
let e2 = Decimal::from_str(entry_2.trim()).unwrap_or(Decimal::ZERO);
let x1 = Decimal::from_str(exit_1.trim()).unwrap_or(Decimal::ZERO);
let x2 = Decimal::from_str(exit_2.trim()).unwrap_or(Decimal::ZERO);
let f = Decimal::from_str(fees.trim()).unwrap_or(Decimal::ZERO);
let fd1 = Decimal::from_str(funding_1.trim()).unwrap_or(Decimal::ZERO);
let fd2 = Decimal::from_str(funding_2.trim()).unwrap_or(Decimal::ZERO);
// Partial/failed close: any leg missing a price → gross unknown.
let both_filled = !e1.is_zero() && !e2.is_zero() && !x1.is_zero() && !x2.is_zero();
let gross = if !both_filled {
Decimal::ZERO
} else if is_long_1 {
(x1 - e1) * q + (e2 - x2) * q
} else {
(e1 - x1) * q + (x2 - e2) * q
};
let net = gross - f + fd1 + fd2;
let notional = if both_filled { q * (e1 + e2) / decm!(2) } else { Decimal::ZERO };
let net_pct = if notional.is_zero() { Decimal::ZERO } else { net / notional * decm!(100) };
(gross, net, net_pct)
}
// ── Storage ──────────────────────────────────────────────────────────────────
/// One leg of a reconciliation close — the fill data needed to persist an
/// `orders` row alongside the trade state transition. Passed to
/// `finalize_reconciliation_close` so both writes happen in one transaction.
pub struct ReconcileLegFill {
pub order_id: u64,
pub exchange_id: i64,
pub purpose: String,
pub side: String,
pub qty: Decimal,
pub fill_price: Decimal,
pub fee: Decimal,
}
pub struct Storage {
conn: Connection,
}
impl Storage {
/// Open a read-only connection (no schema init, no WAL setup).
/// Safe for concurrent reads alongside the main write connection.
pub fn open_read_only(path: &str) -> Result<Self> {
let conn = Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
).with_context(|| format!("Failed to open read-only SQLite at {path}"))?;
conn.pragma_update(None, "busy_timeout", 5000)?;
Ok(Self { conn })
}
pub fn open(path: &str) -> Result<Self> {
if let Some(parent) = std::path::Path::new(path).parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(path)
.with_context(|| format!("Failed to open SQLite database at {path}"))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.execute_batch(SCHEMA)?;
// Seed known exchanges with stable IDs.
let now_ms = crate::utils::time::current_unix_ms();
conn.execute(
"INSERT OR IGNORE INTO exchanges (id, name, display_name, exchange_type, added_at_ms)
VALUES (1, 'lighter', 'Lighter.xyz', 'dex_onchain', ?1)",
params![now_ms],
)?;
conn.execute(
"INSERT OR IGNORE INTO exchanges (id, name, display_name, exchange_type, added_at_ms)
VALUES (2, 'extended', 'Extended.exchange', 'dex_offchain', ?1)",
params![now_ms],
)?;
// zero1 = 01.xyz / N1 / Nord engine — Solana-based perpetual DEX. Display
// label "01" matches the brand on the website; module name "zero1" is
// used everywhere in code/config/secrets to keep grep clean and avoid
// TOML quoting (a section name starting with a digit must be quoted).
conn.execute(
"INSERT OR IGNORE INTO exchanges (id, name, display_name, exchange_type, added_at_ms)
VALUES (3, 'zero1', '01', 'dex_offchain', ?1)",
params![now_ms],
)?;
conn.execute(
"INSERT OR IGNORE INTO exchanges (id, name, display_name, exchange_type, added_at_ms)
VALUES (4, 'hyperliquid', 'Hyperliquid', 'dex_onchain', ?1)",
params![now_ms],
)?;
conn.execute(
"INSERT OR IGNORE INTO exchanges (id, name, display_name, exchange_type, added_at_ms)
VALUES (5, 'bybit', 'Bybit', 'cex', ?1)",
params![now_ms],
)?;
// Additional exchanges are registered via ensure_exchange() at startup.
// Migrations (idempotent ALTER TABLE pattern):
let _ = conn.execute_batch("ALTER TABLE orders ADD COLUMN signal_price TEXT DEFAULT '0';");
let _ = conn.execute_batch("ALTER TABLE exchange_symbols ADD COLUMN limit_price_cap REAL;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN entry_spread_bps TEXT DEFAULT '0';");
// Strategy config snapshot columns (replaces config_json blob).
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_entry_bps TEXT;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_include_fees_in_entry INTEGER;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_exit_mode TEXT;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_exit_bps TEXT;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_exit_pnl_pct TEXT;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_include_fees_in_exit INTEGER;");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN cfg_leverage INTEGER;");
// Drop legacy config_json column (replaced by cfg_* typed columns).
// SQLite 3.35+ (2021) supports DROP COLUMN; older versions will silently no-op.
let _ = conn.execute_batch("ALTER TABLE trades DROP COLUMN config_json;");
let _ = conn.execute_batch("ALTER TABLE optimizer_results ADD COLUMN min_exit_spread_bps REAL;");
let _ = conn.execute_batch("ALTER TABLE optimizer_results ADD COLUMN entry_spike_bps REAL;");
let _ = conn.execute_batch("ALTER TABLE optimizer_results ADD COLUMN exit_spike_bps REAL;");
let _ = conn.execute_batch("ALTER TABLE optimizer_results ADD COLUMN max_pos_per_symbol INTEGER;");
// Per-position funding tracking + SL trigger price persistence.
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN funding_1 TEXT DEFAULT '0';");
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN funding_2 TEXT DEFAULT '0';");
let _ = conn.execute_batch("ALTER TABLE orders ADD COLUMN trigger_price TEXT DEFAULT '0';");
let _ = conn.execute_batch("ALTER TABLE orders ADD COLUMN updated_at_ms INTEGER NOT NULL DEFAULT 0;");
let _ = conn.execute_batch("ALTER TABLE orders ADD COLUMN move_count INTEGER NOT NULL DEFAULT 0;");
// 2026-04-14: Per-leg local wall-clock recv timestamp at signal time
// (ms). Mirrors `exchange_ts_ms` but measured in our own clock so the
// `Recv→Sig` UI metric has uniform semantics across all venues,
// including ZERO1 which does not report event time.
let _ = conn.execute_batch("ALTER TABLE orders ADD COLUMN recv_ts_ms INTEGER DEFAULT 0;");
// 2026-04-14: Time (ms, local wall clock) when the close signal fired
// for this trade. Symmetric to entry-side `signal_at_ms`. Used by the
// Recv→Sig UI metric for close orders — without it, close orders
// incorrectly reused the entry signal time (hours stale for any
// long-held position).
let _ = conn.execute_batch("ALTER TABLE trades ADD COLUMN exit_signal_at_ms INTEGER DEFAULT 0;");
// Per-pair symbol blocking: second exchange ID for pair-specific blocks.
let _ = conn.execute_batch("ALTER TABLE symbol_blocks ADD COLUMN exchange_id_2 INTEGER REFERENCES exchanges(id);");
// Wall-clock receive-time staleness filter (added 2026-04-10 after the
// LIT/AVAX phantom-signal incident — see commit 1f6bad0 + follow-up).
let _ = conn.execute_batch("ALTER TABLE symbol_overrides ADD COLUMN max_recv_staleness_secs REAL;");
// 2026-04-14: replace `max_recv_age_secs` (Recv→Sig only) with
// `max_tick_age_ms` which covers Tick→Recv + Recv→Sig in a single
// gate via `last_exchange_ts`. No backward compat — old values drop.
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN max_recv_age_secs;");
let _ = conn.execute_batch("ALTER TABLE symbol_overrides ADD COLUMN max_tick_age_ms REAL;");
// 2026-04-10: drop legacy freshness/gap filter columns. The bot's
// runtime spread_detector and the sim both use the recv-time filters
// exclusively now; these columns are dead. SQLite ≥3.35 supports
// DROP COLUMN; older versions silently no-op (which is fine — the
// columns become unused but harmless).
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN max_tick_staleness_secs;");
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN max_tick_age_secs;");
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN entry_gap_secs;");
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN exit_gap_secs;");
// 2026-04-13: per-exchange halt scope. NULL = global halt
// (KillSwitch/ManualPause/ApiErrorsPermanent), CSV of i64 db_ids =
// halt only blocks signals touching those exchanges.
let _ = conn.execute_batch("ALTER TABLE trading_halts ADD COLUMN affected_exchanges TEXT;");
// 2026-04-15: drop trades.fees column. It was a synthetic value computed
// from config fee_rate × notional and diverged from the actual exchange
// fees stored per-order in orders.fee. Single source of truth now:
// SUM(orders.fee) for every read. See load_* functions for the subquery.
let _ = conn.execute_batch("ALTER TABLE trades DROP COLUMN fees;");
// 2026-04-15: drop trades.net_pnl and trades.net_pnl_pct. Same reason as
// `fees` above — both are pure derivatives of (orders entry prices,
// orders exit prices, orders.fee, trades.qty, trades.is_long_1,
// trades.funding_1/2). Anything trade-level is already stored (funding,
// qty, direction); PnL is just arithmetic over orders + those fields.
// Keeping it as a column meant a parallel source of truth that
// inevitably drifted. Reads now derive net PnL inline from the same
// JOINs that already fetch entry/exit prices.
let _ = conn.execute_batch("ALTER TABLE trades DROP COLUMN net_pnl;");
let _ = conn.execute_batch("ALTER TABLE trades DROP COLUMN net_pnl_pct;");
// Drop legacy CSV-era columns from exchange_symbols (market_params.csv removed).
let _ = conn.execute_batch("ALTER TABLE exchange_symbols DROP COLUMN quote_asset;");
let _ = conn.execute_batch("ALTER TABLE exchange_symbols DROP COLUMN price_step;");
let _ = conn.execute_batch("ALTER TABLE exchange_symbols DROP COLUMN mmf_updated_at;");
let _ = conn.execute_batch("ALTER TABLE exchange_symbols DROP COLUMN status;");
// 2026-04-17: merge entry_tick_max_spike_bps + exit_tick_max_spike_bps
// into a single tick_max_spike_bps. Both servers have 0 rows so no
// data migration needed — just reshape the schema.
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN entry_tick_max_spike_bps;");
let _ = conn.execute_batch("ALTER TABLE symbol_overrides DROP COLUMN exit_tick_max_spike_bps;");
let _ = conn.execute_batch("ALTER TABLE symbol_overrides ADD COLUMN tick_max_spike_bps REAL;");
// Mark stale optimizer runs as interrupted (from previous crash/restart)
let _ = conn.execute(
"UPDATE optimizer_runs SET status = 'interrupted' WHERE status = 'running'",
[],
);
Ok(Self { conn })
}
/// Direct access to the connection (for early startup queries before full API).
pub fn conn_ref(&self) -> &Connection {
&self.conn
}
// ── Symbols ──────────────────────────────────────────────────────────────
/// Upsert one row per (symbol, exchange_id) into exchange_symbols.
/// Data comes from exchange REST APIs at startup (via `MarketMeta`).
pub fn upsert_exchange_symbol(&self, s: &ExchangeSymbolConfig) -> Result<()> {
self.conn.execute(
"INSERT INTO exchange_symbols (
symbol, exchange_id, market_name,
size_decimals, price_decimals,
qty_step, min_qty, max_leverage,
mmf, limit_price_cap
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)
ON CONFLICT(symbol, exchange_id) DO UPDATE SET
market_name=COALESCE(NULLIF(excluded.market_name,''), exchange_symbols.market_name),
size_decimals=excluded.size_decimals,
price_decimals=excluded.price_decimals,
qty_step=excluded.qty_step,
min_qty=excluded.min_qty,
max_leverage=excluded.max_leverage,
mmf=COALESCE(excluded.mmf, exchange_symbols.mmf),
limit_price_cap=COALESCE(excluded.limit_price_cap, exchange_symbols.limit_price_cap)",
params![
s.symbol, s.exchange_id, s.market_name,
s.size_decimals, s.price_decimals,
s.qty_step, s.min_qty, s.max_leverage,
s.mmf, s.limit_price_cap,
],
)?;
Ok(())
}
/// Load all exchange_symbols rows.
pub fn load_exchange_symbols(&self) -> Result<Vec<ExchangeSymbolConfig>> {
let mut stmt = self.conn.prepare(
"SELECT symbol, exchange_id, market_name,
size_decimals, price_decimals,
qty_step, min_qty, max_leverage,
mmf, limit_price_cap
FROM exchange_symbols"
)?;
let rows = stmt.query_map([], |row| {
Ok(ExchangeSymbolConfig {
symbol: row.get(0)?,
exchange_id: row.get(1)?,
market_name: row.get::<_, String>(2).unwrap_or_default(),
size_decimals: row.get(3)?,
price_decimals: row.get(4)?,
qty_step: row.get(5)?,
min_qty: row.get(6)?,
max_leverage: row.get(7)?,
mmf: row.get(8)?,
limit_price_cap: row.get(9)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to load exchange symbols")
}
/// Returns (exchange_id, max_leverage) pairs for a given symbol.
pub fn get_max_leverage_for_symbol(&self, symbol: &str) -> Result<Vec<(i64, i32)>> {
let mut stmt = self.conn.prepare(
"SELECT exchange_id, max_leverage FROM exchange_symbols WHERE symbol = ?1"
)?;
let rows = stmt.query_map([symbol], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?))
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to load max leverage for symbol")
}
// ── Operator overrides ────────────────────────────────────────────────────
/// Upsert an operator override for (symbol, exchange_id, field).
/// Used by web UI / CLI to set manual mmf or api_alias overrides.
pub fn upsert_override(&self, symbol: &str, exchange_id: i64, field: &str, value: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO exchange_symbol_overrides (symbol, exchange_id, field_name, value)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(symbol, exchange_id, field_name) DO UPDATE SET
value=excluded.value, updated_at=datetime('now')",
params![symbol, exchange_id, field, value],
)?;
Ok(())
}
/// Load all overrides as (symbol, exchange_id, field, value) tuples.
pub fn load_overrides(&self) -> Result<Vec<(String, i64, String, String)>> {
let mut stmt = self.conn.prepare(
"SELECT symbol, exchange_id, field_name, value FROM exchange_symbol_overrides"
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to load overrides")
}
/// Load api_alias overrides → HashMap<alias, canonical_symbol>.
pub fn load_api_aliases(&self, exchange_id: i64) -> Result<std::collections::HashMap<String, String>> {
let mut stmt = self.conn.prepare(
"SELECT value, symbol FROM exchange_symbol_overrides
WHERE exchange_id = ?1 AND field_name = 'api_alias'"
)?;
let rows = stmt.query_map([exchange_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
rows.collect::<std::result::Result<std::collections::HashMap<_, _>, _>>()
.context("Failed to load api aliases")
}
/// Load `PairsParams` from DB. Convenience wrapper over `load_pairs_params_rows`.
pub fn load_pairs_params(&self) -> arb_common::config::PairsParams {
match self.load_pairs_params_rows() {
Ok(rows) => arb_common::config::PairsParams::from_rows(rows),
Err(e) => {
tracing::warn!(error = %e, "Failed to load pairs params from DB, using defaults");
arb_common::config::PairsParams::default()
}
}
}
/// Load market params rows suitable for `PairsParams::from_rows()`.
/// Returns (symbol, exchange_name, SymbolMarketRow) tuples.
pub fn load_pairs_params_rows(&self) -> Result<Vec<(String, String, arb_common::config::SymbolMarketRow)>> {
let mut stmt = self.conn.prepare(
"SELECT es.symbol, e.name,
es.max_leverage, es.mmf, es.size_decimals, es.qty_step, es.min_qty
FROM exchange_symbols es
JOIN exchanges e ON e.id = es.exchange_id"
)?;
let rows = stmt.query_map([], |row| {
let symbol: String = row.get(0)?;
let exchange_name: String = row.get(1)?;
let qty_step_str: String = row.get::<_, String>(5).unwrap_or_default();
let min_qty_str: String = row.get::<_, String>(6).unwrap_or_default();
Ok((symbol, exchange_name, arb_common::config::SymbolMarketRow {
max_leverage: row.get(2)?,
mmf: row.get(3)?,
size_decimals: row.get::<_, Option<i32>>(4).ok().flatten().map(|v| v as u32),
qty_step: qty_step_str.parse::<f64>().ok(),
min_qty: min_qty_str.parse::<f64>().ok(),
}))
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to load pairs params rows")
}
/// Apply mmf overrides from exchange_symbol_overrides to exchange_symbols.
/// Called after API data is upserted so operator values take precedence.
pub fn apply_mmf_overrides(&self) -> Result<usize> {
let count = self.conn.execute(
"UPDATE exchange_symbols SET mmf = CAST(o.value AS REAL)
FROM exchange_symbol_overrides o
WHERE exchange_symbols.symbol = o.symbol
AND exchange_symbols.exchange_id = o.exchange_id
AND o.field_name = 'mmf'",
[],
)?;
Ok(count)
}
// ── Transaction batching ─────────────────────────────────────────────────
/// Run multiple write operations inside a single SQLite transaction.
/// Commits if the closure returns `Ok`, rolls back on `Err` or panic.
///
/// Used by hot-path entry/close DB writes (`spawn_blocking` task)
/// to amortize fsync cost across multiple INSERTs and to give
/// crash-atomicity for the trade/orders/signal batch.
///
/// Uses `BEGIN IMMEDIATE` so the write lock is acquired at BEGIN time,
/// where `busy_timeout` applies. With `BEGIN DEFERRED` (the default),
/// lock upgrades mid-transaction bypass `busy_timeout` entirely — SQLite
/// returns SQLITE_BUSY immediately, causing lost writes.
pub fn with_transaction<F, R>(&self, f: F) -> Result<R>
where
F: FnOnce(&Self) -> Result<R>,
{
let tx = rusqlite::Transaction::new_unchecked(
&self.conn,
rusqlite::TransactionBehavior::Immediate,
).context("BEGIN IMMEDIATE failed")?;
let result = f(self);
match result {
Ok(r) => {
tx.commit().context("COMMIT failed")?;
Ok(r)
}
Err(e) => {
// Transaction guard rolls back on drop, but be explicit so
// failures in rollback are surfaced to logs.
if let Err(rb) = tx.rollback() {
tracing::warn!(error = %rb, "Transaction rollback failed");
}
Err(e)
}
}
}
// ── Orders (persistent lifecycle) ────────────────────────────────────────
pub fn insert_order(&self, o: &OrderRecord) -> Result<u32> {
// Compute next session_seq: max seq within the same session + 1
let session_seq: u32 = self.conn.query_row(
"SELECT COALESCE(MAX(o.session_seq), 0) + 1
FROM orders o JOIN trades t ON o.trade_id = t.id
WHERE t.session_id = (SELECT session_id FROM trades WHERE id = ?1)",
params![o.trade_id],
|r| r.get(0),
).unwrap_or(1);
self.conn.execute(
"INSERT OR REPLACE INTO orders (
order_id, trade_id, session_seq, symbol, exchange_id, side,
order_type, purpose, qty, limit_price, trigger_price, status,
placed_at_ms, exchange_ts_ms, recv_ts_ms, signal_price
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,'placed',?12,?13,?14,?15)",
params![
o.order_id,
o.trade_id,
session_seq,
o.symbol,
o.exchange_id,
o.side,
o.order_type,
o.purpose,
dec(o.qty),
dec(o.limit_price),
dec(o.trigger_price),
o.placed_at_ms,
o.exchange_ts_ms,
o.recv_ts_ms,
dec(o.signal_price),
],
)?;
Ok(session_seq)
}
/// Update order with fill data. Handles first vs subsequent fills.
pub fn update_order_fill(
&self,
order_id: u64,
filled_qty: Decimal,
avg_price: Decimal,
fill_at_ms: u64,
is_complete: bool,
fee: Decimal,
) -> Result<()> {
let status = if is_complete { "filled" } else { "partial" };
let closed_at = if is_complete { fill_at_ms } else { 0 };
self.conn.execute(
"UPDATE orders SET
filled_qty = ?1, avg_fill_price = ?2,
first_fill_at_ms = CASE WHEN first_fill_at_ms = 0 THEN ?3 ELSE first_fill_at_ms END,
last_fill_at_ms = ?3,
fee = ?4, status = ?5, closed_at_ms = ?6
WHERE order_id = ?7",
params![
dec(filled_qty),
dec(avg_price),
fill_at_ms,
dec(fee),
status,
closed_at,
order_id,
],
)?;
Ok(())
}
/// Update fee for an order with actual value from exchange REST API.
pub fn update_order_fee(&self, order_id: u64, fee: Decimal) -> Result<()> {
self.conn.execute(
"UPDATE orders SET fee = ?1 WHERE order_id = ?2",
params![dec(fee), order_id],
)?;
Ok(())
}
pub fn update_order_status(&self, order_id: u64, status: &str, closed_at_ms: u64, close_reason: &str) -> Result<()> {
self.conn.execute(
"UPDATE orders SET status = ?1,
closed_at_ms = CASE WHEN closed_at_ms > 0 THEN closed_at_ms ELSE ?2 END,
close_reason = ?3
WHERE order_id = ?4",
params![status, closed_at_ms, close_reason, order_id],
)?;
Ok(())
}
/// Store exchange-side identifiers for an order (tx_hash from Lighter, order_id from Extended).
pub fn set_order_exchange_id(&self, order_id: u64, tx_hash: Option<&str>, exchange_order_id: Option<&str>) -> Result<()> {
if let Some(h) = tx_hash {
self.conn.execute(
"UPDATE orders SET tx_hash = ?1 WHERE order_id = ?2",
params![h, order_id],
)?;
}
if let Some(eid) = exchange_order_id {
self.conn.execute(
"UPDATE orders SET exchange_order_id = ?1 WHERE order_id = ?2",
params![eid, order_id],
)?;
}
Ok(())
}
/// Persist a fill that happened on an external leg (liquidation, external SL,
/// manual close from the exchange UI). External-leg fills never flow through
/// the normal `track_order` → `place_order` → WS-fill pipeline, so without
/// this helper the `orders` table would be missing the close row for that
/// leg and `derive_pnl` would report `both_filled=false` → gross=0 (the
/// 2026-04-18 MEGA liquidation bug: DB had entry_1, entry_2, sl_*, and only
/// `exit_unwind_2` for the surviving leg; `exit_unwind_1` for the
/// liquidated leg was missing → UI showed ~$0 for a real −$1.46 loss).
///
/// Inserts a filled `orders` row in one shot, wrapped in a transaction
/// so partial state (order row without fill data) can't survive a
/// crash mid-way. The matching trade must already exist (FK).
///
/// `purpose` should be `exit_unwind_1` or `exit_unwind_2`.
/// `close_reason` should be `"liquidation"`, `"external_sl"`, or
/// `"external_manual"`.
#[allow(clippy::too_many_arguments)]
pub fn persist_external_leg_fill(
&self,
trade_id: &str,
order_id: u64,
symbol: &str,
exchange_id: i64,
purpose: &str,
side: &str,
qty: Decimal,
fill_price: Decimal,
fee: Decimal,
fill_at_ms: u64,
close_reason: &str,
) -> Result<()> {
self.with_transaction(|db| {
db.persist_external_leg_fill_inner(
trade_id, order_id, symbol, exchange_id, purpose, side,
qty, fill_price, fee, fill_at_ms, close_reason,
)
})
}
/// Transaction-less variant of `persist_external_leg_fill`. Used by
/// callers that are already inside a `with_transaction` block (rusqlite
/// rejects nested BEGIN IMMEDIATE). All the actual INSERT/UPDATE
/// statements live here; the public wrapper adds the transaction.
#[allow(clippy::too_many_arguments)]
fn persist_external_leg_fill_inner(
&self,
trade_id: &str,
order_id: u64,
symbol: &str,
exchange_id: i64,
purpose: &str,
side: &str,
qty: Decimal,
fill_price: Decimal,
fee: Decimal,
fill_at_ms: u64,
close_reason: &str,
) -> Result<()> {
let rec = OrderRecord {
order_id,
trade_id: trade_id.to_string(),
symbol: symbol.to_string(),
exchange_id,
side: side.to_string(),
order_type: "ioc".to_string(),
purpose: purpose.to_string(),
qty,
// The exchange did the matching — we record the observed fill
// price as both the "limit" (synthetic) and signal_price.
limit_price: fill_price,
trigger_price: Decimal::ZERO,
placed_at_ms: fill_at_ms,
exchange_ts_ms: fill_at_ms,
recv_ts_ms: fill_at_ms,
signal_price: fill_price,
};
self.insert_order(&rec)?;
self.update_order_fill(order_id, qty, fill_price, fill_at_ms, true, fee)?;
self.update_order_status(order_id, "filled", fill_at_ms, close_reason)?;
Ok(())
}
/// Record the equity of one exchange at session start. Callers invoke
/// once per exchange right after `Initial balances fetched` at bot
/// startup. `INSERT OR IGNORE` — the first snapshot for a
/// `(session_id, exchange_id)` wins; subsequent bot restarts within
/// the same session do not overwrite it, so the baseline against
/// which `Balance Δ` is computed stays stable across restarts.
pub fn insert_session_start_balance(
&self,
session_id: i64,
exchange_id: i64,
balance_usd: Decimal,
captured_at_ms: u64,
) -> Result<()> {
self.conn.execute(
"INSERT OR IGNORE INTO session_start_balances
(session_id, exchange_id, balance_usd, captured_at_ms)
VALUES (?1, ?2, ?3, ?4)",
params![session_id, exchange_id, dec(balance_usd), captured_at_ms],
)?;
Ok(())
}
/// Load per-exchange start-of-session equity for the given session.
/// Returns `(exchange_id, balance_usd)` pairs ordered by exchange_id.
/// Empty vec if the session pre-dates the feature.
pub fn load_session_start_balances(
&self,
session_id: i64,
) -> Result<Vec<(i64, Decimal)>> {
let mut stmt = self.conn.prepare(
"SELECT exchange_id, balance_usd
FROM session_start_balances
WHERE session_id = ?1
ORDER BY exchange_id",
)?;
let rows = stmt.query_map(params![session_id], |r| {
let eid: i64 = r.get(0)?;
let bal_s: String = r.get(1)?;
let bal = Decimal::from_str(bal_s.trim()).unwrap_or(Decimal::ZERO);
Ok((eid, bal))
})?;
let mut out = Vec::new();
for row in rows { out.push(row?); }
Ok(out)
}
/// Record a reconciliation-time close as an `orders` row on an existing
/// trade. Used for Direction-mismatch and ORPHAN paths in
/// `main.rs` reconciliation where a DB trade exists but neither leg
/// had an `exit_%` order written (bot was killed mid-position or the
/// exchange closed our leg out-of-band before we recorded anything).
///
/// `trade_id` must already exist in the `trades` table.
/// `purpose` is `"exit_unwind_1"` or `"exit_unwind_2"` per which leg
/// we closed in reconciliation.
/// `close_reason` is a short tag for the UI (`"reconcile_orphan"`,
/// `"reconcile_dir_mismatch"`).
///
/// Lower-level single-leg primitive: does NOT touch trade `state`.
/// Production callers should use `finalize_reconciliation_close`
/// instead, which bundles 0/1/2 leg inserts + the state flip in one
/// transaction. Kept for the test that verifies the `orders` row
/// shape independent of state transitions.
#[allow(clippy::too_many_arguments)]
pub fn record_reconciliation_exit(
&self,
trade_id: &str,
order_id: u64,
symbol: &str,
exchange_id: i64,
purpose: &str,
side: &str,
qty: Decimal,
fill_price: Decimal,
fee: Decimal,
closed_at_ms: u64,
close_reason: &str,
) -> Result<()> {
// Re-use the same wire path as external-leg fills — both are
// "filled synchronously from outside the normal IOC pipeline".
// `persist_external_leg_fill` wraps its 3 writes in a transaction.
self.persist_external_leg_fill(
trade_id, order_id, symbol, exchange_id, purpose, side,
qty, fill_price, fee, closed_at_ms, close_reason,
)
}
/// Atomic finalize of a reconciliation close: persist 0/1/2 leg fills
/// AND flip the trade `state` to `closed` in one transaction. Prevents
/// the 2026-04-18 split where `update_trade_state_by_symbol` fired
/// unconditionally after `close_position` → a failed close still
/// marked the trade closed in DB, leaving an orphan on the exchange.
///
/// Callers pass `legs` as `Option<ReconcileLegFill>` for each of the
/// two exchange legs (None = leg already gone / no fill to record).
/// GHOST path (both None) → only the state flip runs.
pub fn finalize_reconciliation_close(
&self,
trade_id: &str,
symbol: &str,
leg1: Option<ReconcileLegFill>,
leg2: Option<ReconcileLegFill>,
close_reason: &str,
closed_at_ms: u64,
) -> Result<()> {
self.with_transaction(|db| {
for leg in [leg1.as_ref(), leg2.as_ref()].into_iter().flatten() {
db.persist_external_leg_fill_inner(
trade_id,
leg.order_id,
symbol,
leg.exchange_id,
&leg.purpose,
&leg.side,
leg.qty,
leg.fill_price,
leg.fee,
closed_at_ms,
close_reason,
)?;
}
db.conn.execute(
"UPDATE trades SET state = 'closed', closed_at_ms = ?1
WHERE symbol = ?2 AND state IN ('active', 'closing', 'filling')",
params![closed_at_ms as i64, symbol],
)?;
Ok(())
})
}
/// Record a synthetic "orphan cover" trade for the reconciliation LEAK
/// path — an exchange position that has no corresponding DB trade
/// (e.g. our paired leg was lost previously or the position was
/// opened manually). Without this we close the orphan via
/// `close_position`, the cost evaporates (trait returns `()`), and
/// the UI can't see the expense (the 2026-04-18 HL naked short
/// bleed during the MEGA price spike).
///
/// Creates:
/// * one `trades` row with `state='closed'`, `exit_reason='orphan_cover'`
/// * one `entry_1` order on `orphan_exchange_id` @ `entry_price`
/// (the position's observed entry price from `fetch_open_positions`)
/// * one `entry_2` + one `exit_unwind_2` order on
/// `hedge_exchange_id` @ `entry_price` — a synthetic "hedge" leg
/// whose entry==exit so `(x2-e2)*q = 0` contributes nothing to
/// `derive_pnl`; it exists purely to keep the AGG_TRADES_SELECT
/// LEFT JOIN deterministic. Pass a different exchange ID from
/// `orphan_exchange_id` to avoid cartesian-product duplication
/// in the aggregation query.
/// * one `exit_unwind_1` order on `orphan_exchange_id` @ `cover_price`
/// — the actual buy-back / sell-back that matters.
///
/// Returns the synthetic `trade_id`.
#[allow(clippy::too_many_arguments)]
pub fn record_orphan_cover_trade(
&self,
session_id: i64,
symbol: &str,
orphan_exchange_id: i64,
hedge_exchange_id: i64,
qty: Decimal,
was_long: bool,
entry_price: Decimal,
cover_price: Decimal,
cover_fee: Decimal,
closed_at_ms: u64,
orphan_order_id: u64,
hedge_entry_order_id: u64,
hedge_exit_order_id: u64,
orphan_exit_order_id: u64,
) -> Result<String> {
if orphan_exchange_id == hedge_exchange_id {
// Same-exchange fallback — the aggregation JOIN will duplicate
// rows. This only happens in pathological configs (single
// exchange), production runs 4. Warn so the operator can spot.
tracing::warn!(
symbol, exchange_id = orphan_exchange_id,
"record_orphan_cover_trade: hedge_exchange_id == orphan_exchange_id — aggregation will double-count this trade"
);
}
let trade_id = uuid::Uuid::new_v4().to_string();
// Direction: `was_long` refers to the orphan leg. Entry side:
// long orphan entered with a BUY, closed by SELL
// short orphan entered with a SELL, closed by BUY
// For the synthetic hedge leg we use the opposite side (as if it
// were the other leg of an arb) but since e2==x2 the direction
// doesn't affect PnL.
let orphan_entry_side = if was_long { "buy" } else { "sell" };
let orphan_exit_side = if was_long { "sell" } else { "buy" };
let hedge_entry_side = if was_long { "sell" } else { "buy" };
let hedge_exit_side = if was_long { "buy" } else { "sell" };
// Wrap the trade + 4 orders in a single transaction. Partial
// state (trade row without all orders, or some orders with
// `status='placed'` because the follow-up UPDATE failed) would
// silently poison `derive_pnl` — gross falls back to 0 on any
// missing/unfilled leg and the UI loses the expense.
let tid_ref = &trade_id;
self.with_transaction(|db| {
db.conn.execute(
"INSERT INTO trades (
id, session_id, session_seq, symbol, is_long_1,
exchange_id_1, exchange_id_2, qty, state,
opened_at_ms, closed_at_ms, signal_at_ms, exit_reason,
entry_spread_bps, funding_1, funding_2
) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, 'closed',
?8, ?8, ?8, 'orphan_cover',
'0', '0', '0')",
params![
tid_ref, session_id, symbol, was_long as i64,
orphan_exchange_id, hedge_exchange_id, dec(qty),
closed_at_ms,
],
)?;
// Entry on orphan leg (what we observed on the exchange).
db.persist_external_leg_fill_inner(
tid_ref, orphan_order_id, symbol, orphan_exchange_id,
"entry_1", orphan_entry_side, qty, entry_price, Decimal::ZERO,
closed_at_ms, "orphan_observed_entry",
)?;
// Synthetic hedge entry + exit — entry==exit so contributes 0 PnL.
db.persist_external_leg_fill_inner(
tid_ref, hedge_entry_order_id, symbol, hedge_exchange_id,
"entry_2", hedge_entry_side, qty, entry_price, Decimal::ZERO,
closed_at_ms, "orphan_synthetic_hedge",
)?;
db.persist_external_leg_fill_inner(
tid_ref, hedge_exit_order_id, symbol, hedge_exchange_id,
"exit_unwind_2", hedge_exit_side, qty, entry_price, Decimal::ZERO,
closed_at_ms, "orphan_synthetic_hedge",
)?;
// Real exit: the cover fill that actually moved money.
db.persist_external_leg_fill_inner(
tid_ref, orphan_exit_order_id, symbol, orphan_exchange_id,
"exit_unwind_1", orphan_exit_side, qty, cover_price, cover_fee,
closed_at_ms, "orphan_cover",
)?;
Ok(())
})?;
Ok(trade_id)
}
/// Update an SL order after a successful move (drift or renewal).
/// Lookup by trade_id + purpose (stable key), updates order_id (may change on Extended),
/// trigger/limit prices, and increments move_count.
pub fn update_sl_after_move(
&self,
trade_id: &str,
purpose: &str,
new_order_id: u64,
trigger_price: &str,
limit_price: &str,
updated_at_ms: u64,
) -> Result<()> {
self.conn.execute(
"UPDATE orders SET order_id = ?1, exchange_order_id = ?1, trigger_price = ?2, limit_price = ?3,
updated_at_ms = ?4, move_count = move_count + 1
WHERE trade_id = ?5 AND purpose = ?6 AND status = 'placed'",
params![new_order_id, trigger_price, limit_price, updated_at_ms, trade_id, purpose],
)?;
Ok(())
}
/// Cancel all non-terminal orders on startup (clean mode — everything is wiped).
pub fn cancel_stale_orders(&self) -> Result<u64> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let count = self.conn.execute(
"UPDATE orders SET status = 'cancelled', closed_at_ms = ?1, close_reason = 'bot restarted'
WHERE status IN ('placed', 'partial')",
params![now_ms],
)?;
Ok(count as u64)
}
/// Cancel only non-SL stale orders (resume mode — SL orders are preserved on exchanges).
pub fn cancel_non_sl_orders(&self) -> Result<u64> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let count = self.conn.execute(
"UPDATE orders SET status = 'cancelled', closed_at_ms = ?1, close_reason = 'bot restarted'
WHERE status IN ('placed', 'partial') AND purpose NOT LIKE 'sl_%'",
params![now_ms],
)?;
Ok(count as u64)
}
/// Load active SL orders from DB (status='placed') for resume into active_orders DashMap.
pub fn load_active_sl_orders(&self) -> Result<Vec<ActiveSlOrder>> {
let mut stmt = self.conn.prepare(
"SELECT order_id, session_seq, trade_id, symbol, exchange_id, side, order_type, purpose, qty, limit_price, trigger_price, placed_at_ms, exchange_ts_ms, recv_ts_ms, exchange_order_id
FROM orders
WHERE status = 'placed' AND purpose LIKE 'sl_%'"
)?;
let rows = stmt.query_map([], |row| {
let exchange_order_id_str: Option<String> = row.get::<_, Option<String>>(14).unwrap_or(None);
let exchange_order_id = exchange_order_id_str.and_then(|s| s.parse::<u64>().ok());
Ok(ActiveSlOrder {
order_id: row.get(0)?,
session_seq: row.get::<_, u32>(1).unwrap_or(0),
trade_id: row.get(2)?,
symbol: row.get(3)?,
exchange_id: row.get(4)?,
side: row.get(5)?,
order_type: row.get(6)?,
purpose: row.get(7)?,
qty: row.get(8)?,
limit_price: row.get(9)?,
trigger_price: row.get::<_, String>(10).unwrap_or_else(|_| "0".to_string()),
placed_at_ms: row.get(11)?,
exchange_ts_ms: row.get::<_, Option<u64>>(12)?.unwrap_or(0),
recv_ts_ms: row.get::<_, Option<u64>>(13)?.unwrap_or(0),
exchange_order_id,
})
})?.filter_map(|r| r.ok()).collect();
Ok(rows)
}
/// Load stale orders for a specific exchange (status='placed'/'partial') for REST recovery.
/// Returns (order_id, client_order_id_str, symbol).
pub fn load_stale_orders_for_exchange(&self, exchange_id: i64) -> Result<Vec<(u64, String, String)>> {
let mut stmt = self.conn.prepare(
"SELECT order_id, symbol FROM orders
WHERE exchange_id = ?1 AND status IN ('placed', 'partial')
ORDER BY placed_at_ms DESC LIMIT 100"
)?;
let rows = stmt.query_map(params![exchange_id], |row| {
let order_id: u64 = row.get(0)?;
let symbol: String = row.get(1)?;
Ok((order_id, order_id.to_string(), symbol))
})?.filter_map(|r| r.ok()).collect();
Ok(rows)
}
/// Fetch all orders linked to a trade (entry, SL, close).
pub fn query_orders_for_trade(&self, trade_id: &str) -> Result<Vec<OrderRow>> {
let mut stmt = self.conn.prepare(
"SELECT o.order_id, o.exchange_id, o.side, o.order_type, o.purpose, o.qty, o.limit_price,
o.status, o.filled_qty, o.avg_fill_price, o.fee, o.placed_at_ms, o.first_fill_at_ms,
o.exchange_ts_ms, o.recv_ts_ms, o.signal_price,
CASE WHEN o.purpose LIKE 'exit%'
THEN t.exit_signal_at_ms ELSE t.signal_at_ms END
FROM orders o
JOIN trades t ON o.trade_id = t.id
WHERE o.trade_id = ?1 AND (o.purpose NOT LIKE 'sl_%' OR o.status = 'filled')
ORDER BY o.placed_at_ms ASC"
)?;
let rows = stmt.query_map(params![trade_id], |row| {
let qty_str: String = row.get(5)?;
let price_str: String = row.get(6)?;
Ok(OrderRow {
order_id: row.get(0)?,
exchange_id: row.get(1)?,
side: row.get(2)?,
order_type: row.get(3)?,
purpose: row.get(4)?,
qty: qty_str.parse().unwrap_or_default(),
limit_price: price_str.parse().unwrap_or_default(),
status: row.get(7)?,
filled_qty: row.get::<_, String>(8).unwrap_or_default(),
avg_fill_price: row.get::<_, String>(9).unwrap_or_default(),
fee: row.get::<_, String>(10).unwrap_or_default(),