1use anyhow::{anyhow, bail, Context, Result};
2use serde::Serialize;
3use std::time::Instant;
4
5use crate::binance::rest::BinanceRestClient;
6use crate::binance::types::BinanceFuturesPositionRisk;
7use crate::config::Config;
8use crate::error::AppError;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum DoctorCommand {
12 Help,
13 Auth {
14 json: bool,
15 },
16 Positions {
17 market: String,
18 symbol: Option<String>,
19 json: bool,
20 },
21 Pnl {
22 market: String,
23 symbol: Option<String>,
24 json: bool,
25 },
26 History {
27 market: String,
28 symbol: Option<String>,
29 json: bool,
30 },
31 SyncOnce {
32 market: String,
33 symbol: Option<String>,
34 json: bool,
35 },
36}
37
38#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "lowercase")]
40enum DoctorStatus {
41 Ok,
42 Warn,
43 Fail,
44}
45
46#[derive(Debug, Serialize)]
47struct DoctorEnvelope<T: Serialize> {
48 status: DoctorStatus,
49 timestamp_ms: u64,
50 command: String,
51 data: T,
52 errors: Vec<String>,
53}
54
55#[derive(Debug, Serialize)]
56struct AuthCheckRow {
57 endpoint: String,
58 ok: bool,
59 code: Option<i64>,
60 message: String,
61}
62
63#[derive(Debug, Serialize)]
64struct AuthReport {
65 rest_base_url: String,
66 futures_rest_base_url: String,
67 spot_key_len: usize,
68 spot_secret_len: usize,
69 futures_key_len: usize,
70 futures_secret_len: usize,
71 checks: Vec<AuthCheckRow>,
72}
73
74#[derive(Debug, Clone, Serialize)]
75struct PositionRow {
76 symbol: String,
77 side: String,
78 qty_abs: f64,
79 qty_signed: f64,
80 entry_price: f64,
81 mark_price: f64,
82 unrealized_api: f64,
83 unrealized_final: f64,
84 selected_source: String,
85}
86
87#[derive(Debug, Serialize)]
88struct PositionsReport {
89 market: String,
90 symbol_filter: Option<String>,
91 count: usize,
92 rows: Vec<PositionRow>,
93}
94
95#[derive(Debug, Serialize)]
96struct PnlRow {
97 symbol: String,
98 side: String,
99 qty_signed: f64,
100 entry_price: f64,
101 mark_price: f64,
102 api_unrealized: f64,
103 fallback_unrealized: f64,
104 final_unrealized: f64,
105 selected_source: String,
106}
107
108#[derive(Debug, Serialize)]
109struct PnlReport {
110 market: String,
111 symbol_filter: Option<String>,
112 count: usize,
113 rows: Vec<PnlRow>,
114}
115
116#[derive(Debug, Serialize)]
117struct HistoryReport {
118 market: String,
119 symbol: String,
120 all_orders_ok: bool,
121 trades_ok: bool,
122 all_orders_count: usize,
123 trades_count: usize,
124 latest_order_ms: Option<u64>,
125 latest_trade_ms: Option<u64>,
126 fetch_latency_ms: u64,
127}
128
129#[derive(Debug, Serialize)]
130struct SyncOnceReport {
131 market: String,
132 symbol: String,
133 auth_spot_ok: bool,
134 auth_futures_ok: bool,
135 futures_positions_count: usize,
136 history_all_orders_ok: bool,
137 history_trades_ok: bool,
138 history_all_orders_count: usize,
139 history_trades_count: usize,
140 total_latency_ms: u64,
141}
142
143pub fn parse_doctor_args(args: &[String]) -> Result<Option<DoctorCommand>> {
144 if args.len() < 2 || args[1] != "doctor" {
145 return Ok(None);
146 }
147 if args.len() == 2 {
148 return Ok(Some(DoctorCommand::Help));
149 }
150 let sub = args.get(2).map(|s| s.as_str()).unwrap_or("");
151 if sub == "help" || sub == "--help" || sub == "-h" {
152 return Ok(Some(DoctorCommand::Help));
153 }
154 let mut json = false;
155 let mut market = "futures".to_string();
156 let mut symbol: Option<String> = None;
157 let mut once = false;
158
159 let mut i = 3usize;
160 while i < args.len() {
161 match args[i].as_str() {
162 "--json" => {
163 json = true;
164 i += 1;
165 }
166 "--once" => {
167 once = true;
168 i += 1;
169 }
170 "--help" | "-h" => {
171 return Ok(Some(DoctorCommand::Help));
172 }
173 "--market" => {
174 let v = args
175 .get(i + 1)
176 .with_context(|| "--market requires a value")?
177 .trim()
178 .to_ascii_lowercase();
179 market = v;
180 i += 2;
181 }
182 "--symbol" => {
183 let v = args
184 .get(i + 1)
185 .with_context(|| "--symbol requires a value")?
186 .trim()
187 .to_ascii_uppercase();
188 symbol = Some(v);
189 i += 2;
190 }
191 unknown => bail!("unknown doctor option: {}", unknown),
192 }
193 }
194
195 let cmd = match sub {
196 "auth" => DoctorCommand::Auth { json },
197 "positions" => DoctorCommand::Positions {
198 market,
199 symbol,
200 json,
201 },
202 "pnl" => DoctorCommand::Pnl {
203 market,
204 symbol,
205 json,
206 },
207 "history" => DoctorCommand::History {
208 market,
209 symbol,
210 json,
211 },
212 "sync" => {
213 if !once {
214 bail!("doctor sync currently requires --once");
215 }
216 DoctorCommand::SyncOnce {
217 market,
218 symbol,
219 json,
220 }
221 }
222 _ => {
223 bail!(
224 "unknown doctor subcommand: '{}'. expected one of: auth, positions, pnl, history, sync",
225 sub
226 )
227 }
228 };
229 Ok(Some(cmd))
230}
231
232pub async fn maybe_run_doctor_from_args(args: &[String]) -> Result<bool> {
233 let Some(cmd) = parse_doctor_args(args)? else {
234 return Ok(false);
235 };
236 run_doctor(cmd).await?;
237 Ok(true)
238}
239
240async fn run_doctor(cmd: DoctorCommand) -> Result<()> {
241 if matches!(cmd, DoctorCommand::Help) {
242 print_doctor_help();
243 return Ok(());
244 }
245
246 let cfg = Config::load()?;
247 let client = BinanceRestClient::new(
248 &cfg.binance.rest_base_url,
249 &cfg.binance.futures_rest_base_url,
250 &cfg.binance.api_key,
251 &cfg.binance.api_secret,
252 &cfg.binance.futures_api_key,
253 &cfg.binance.futures_api_secret,
254 cfg.binance.recv_window,
255 );
256
257 match cmd {
258 DoctorCommand::Help => {}
259 DoctorCommand::Auth { json } => {
260 let report = run_auth(&cfg, &client).await;
261 if json {
262 print_json_envelope("doctor auth", report.status, report.data, report.errors)?;
263 } else {
264 print_auth_text(&report.data, &report.errors);
265 }
266 }
267 DoctorCommand::Positions {
268 market,
269 symbol,
270 json,
271 } => {
272 if market != "futures" {
273 return Err(anyhow!(
274 "doctor positions currently supports only --market futures"
275 ));
276 }
277 let report = run_positions(&client, symbol.clone()).await;
278 if json {
279 print_json_envelope(
280 "doctor positions",
281 report.status,
282 report.data,
283 report.errors,
284 )?;
285 } else {
286 print_positions_text(&report.data, &report.errors);
287 }
288 }
289 DoctorCommand::Pnl {
290 market,
291 symbol,
292 json,
293 } => {
294 if market != "futures" {
295 return Err(anyhow!(
296 "doctor pnl currently supports only --market futures"
297 ));
298 }
299 let report = run_pnl(&client, symbol.clone()).await;
300 if json {
301 print_json_envelope("doctor pnl", report.status, report.data, report.errors)?;
302 } else {
303 print_pnl_text(&report.data, &report.errors);
304 }
305 }
306 DoctorCommand::History {
307 market,
308 symbol,
309 json,
310 } => {
311 let symbol = normalize_symbol_for_market(symbol, &market, &cfg.binance.symbol)?;
312 let report = run_history(&client, &market, symbol).await;
313 if json {
314 print_json_envelope("doctor history", report.status, report.data, report.errors)?;
315 } else {
316 print_history_text(&report.data, &report.errors);
317 }
318 }
319 DoctorCommand::SyncOnce {
320 market,
321 symbol,
322 json,
323 } => {
324 let symbol = normalize_symbol_for_market(symbol, &market, &cfg.binance.symbol)?;
325 let report = run_sync_once(&client, &market, symbol).await;
326 if json {
327 print_json_envelope(
328 "doctor sync --once",
329 report.status,
330 report.data,
331 report.errors,
332 )?;
333 } else {
334 print_sync_once_text(&report.data, &report.errors);
335 }
336 }
337 }
338
339 Ok(())
340}
341
342fn normalize_symbol_for_market(
343 symbol: Option<String>,
344 market: &str,
345 default_symbol: &str,
346) -> Result<String> {
347 let raw = symbol.unwrap_or_else(|| default_symbol.to_ascii_uppercase());
348 let s = raw.trim().to_ascii_uppercase();
349 if s.is_empty() {
350 bail!("symbol must not be empty");
351 }
352 let base = s
353 .trim_end_matches(" (FUT)")
354 .trim_end_matches("#FUT")
355 .trim()
356 .to_string();
357 if base.is_empty() {
358 bail!("symbol must not be empty after normalization");
359 }
360 match market {
361 "spot" | "futures" => Ok(base),
362 _ => bail!("unsupported market '{}', expected spot or futures", market),
363 }
364}
365
366struct ReportOutcome<T> {
367 status: DoctorStatus,
368 data: T,
369 errors: Vec<String>,
370}
371
372async fn run_auth(cfg: &Config, client: &BinanceRestClient) -> ReportOutcome<AuthReport> {
373 let mut checks = Vec::new();
374 let mut errors = Vec::new();
375
376 match client.get_account().await {
377 Ok(_) => checks.push(AuthCheckRow {
378 endpoint: "spot:/api/v3/account".to_string(),
379 ok: true,
380 code: None,
381 message: "OK".to_string(),
382 }),
383 Err(e) => {
384 let (code, msg) = extract_error_code_message(&e);
385 checks.push(AuthCheckRow {
386 endpoint: "spot:/api/v3/account".to_string(),
387 ok: false,
388 code,
389 message: msg.clone(),
390 });
391 errors.push(format!("spot auth failed: {}", msg));
392 }
393 }
394
395 match client.get_futures_account().await {
396 Ok(_) => checks.push(AuthCheckRow {
397 endpoint: "futures:/fapi/v2/account".to_string(),
398 ok: true,
399 code: None,
400 message: "OK".to_string(),
401 }),
402 Err(e) => {
403 let (code, msg) = extract_error_code_message(&e);
404 checks.push(AuthCheckRow {
405 endpoint: "futures:/fapi/v2/account".to_string(),
406 ok: false,
407 code,
408 message: msg.clone(),
409 });
410 errors.push(format!("futures auth failed: {}", msg));
411 }
412 }
413
414 let status = if errors.is_empty() {
415 DoctorStatus::Ok
416 } else {
417 DoctorStatus::Fail
418 };
419
420 ReportOutcome {
421 status,
422 data: AuthReport {
423 rest_base_url: cfg.binance.rest_base_url.clone(),
424 futures_rest_base_url: cfg.binance.futures_rest_base_url.clone(),
425 spot_key_len: cfg.binance.api_key.len(),
426 spot_secret_len: cfg.binance.api_secret.len(),
427 futures_key_len: cfg.binance.futures_api_key.len(),
428 futures_secret_len: cfg.binance.futures_api_secret.len(),
429 checks,
430 },
431 errors,
432 }
433}
434
435async fn run_positions(
436 client: &BinanceRestClient,
437 symbol_filter: Option<String>,
438) -> ReportOutcome<PositionsReport> {
439 match client.get_futures_position_risk().await {
440 Ok(rows) => {
441 let mapped = map_positions(rows, symbol_filter.clone());
442 let status = if mapped.is_empty() {
443 DoctorStatus::Warn
444 } else {
445 DoctorStatus::Ok
446 };
447 ReportOutcome {
448 status,
449 data: PositionsReport {
450 market: "futures".to_string(),
451 symbol_filter,
452 count: mapped.len(),
453 rows: mapped,
454 },
455 errors: Vec::new(),
456 }
457 }
458 Err(e) => {
459 let (_code, msg) = extract_error_code_message(&e);
460 ReportOutcome {
461 status: DoctorStatus::Fail,
462 data: PositionsReport {
463 market: "futures".to_string(),
464 symbol_filter,
465 count: 0,
466 rows: Vec::new(),
467 },
468 errors: vec![msg],
469 }
470 }
471 }
472}
473
474async fn run_pnl(
475 client: &BinanceRestClient,
476 symbol_filter: Option<String>,
477) -> ReportOutcome<PnlReport> {
478 match client.get_futures_position_risk().await {
479 Ok(rows) => {
480 let mut out = Vec::new();
481 for row in rows {
482 if row.position_amt.abs() <= f64::EPSILON {
483 continue;
484 }
485 if let Some(filter) = symbol_filter.as_ref() {
486 if row.symbol.trim().to_ascii_uppercase() != *filter {
487 continue;
488 }
489 }
490 let (final_unr, source) = resolve_unrealized_pnl(
491 row.unrealized_profit,
492 row.mark_price,
493 row.entry_price,
494 row.position_amt,
495 );
496 let fallback = if row.mark_price > f64::EPSILON && row.entry_price > f64::EPSILON {
497 (row.mark_price - row.entry_price) * row.position_amt
498 } else {
499 0.0
500 };
501 out.push(PnlRow {
502 symbol: format!("{} (FUT)", row.symbol.trim().to_ascii_uppercase()),
503 side: side_text(row.position_amt).to_string(),
504 qty_signed: row.position_amt,
505 entry_price: row.entry_price,
506 mark_price: row.mark_price,
507 api_unrealized: row.unrealized_profit,
508 fallback_unrealized: fallback,
509 final_unrealized: final_unr,
510 selected_source: source.to_string(),
511 });
512 }
513 let status = if out.is_empty() {
514 DoctorStatus::Warn
515 } else {
516 DoctorStatus::Ok
517 };
518 ReportOutcome {
519 status,
520 data: PnlReport {
521 market: "futures".to_string(),
522 symbol_filter,
523 count: out.len(),
524 rows: out,
525 },
526 errors: Vec::new(),
527 }
528 }
529 Err(e) => {
530 let (_code, msg) = extract_error_code_message(&e);
531 ReportOutcome {
532 status: DoctorStatus::Fail,
533 data: PnlReport {
534 market: "futures".to_string(),
535 symbol_filter,
536 count: 0,
537 rows: Vec::new(),
538 },
539 errors: vec![msg],
540 }
541 }
542 }
543}
544
545async fn run_history(
546 client: &BinanceRestClient,
547 market: &str,
548 symbol: String,
549) -> ReportOutcome<HistoryReport> {
550 let started = Instant::now();
551 let (
552 orders_ok,
553 orders_count,
554 latest_order_ms,
555 orders_err,
556 trades_ok,
557 trades_count,
558 latest_trade_ms,
559 trades_err,
560 ) = if market == "futures" {
561 let orders = client.get_futures_all_orders(&symbol, 1000).await;
562 let trades = client.get_futures_my_trades_history(&symbol, 1000).await;
563 (
564 orders.is_ok(),
565 orders.as_ref().map(|v| v.len()).unwrap_or(0),
566 orders
567 .as_ref()
568 .ok()
569 .and_then(|v| v.iter().map(|o| o.update_time.max(o.time)).max()),
570 orders.err().map(|e| e.to_string()),
571 trades.is_ok(),
572 trades.as_ref().map(|v| v.len()).unwrap_or(0),
573 trades
574 .as_ref()
575 .ok()
576 .and_then(|v| v.iter().map(|t| t.time).max()),
577 trades.err().map(|e| e.to_string()),
578 )
579 } else {
580 let orders = client.get_all_orders(&symbol, 1000).await;
581 let trades = client.get_my_trades_history(&symbol, 1000).await;
582 (
583 orders.is_ok(),
584 orders.as_ref().map(|v| v.len()).unwrap_or(0),
585 orders
586 .as_ref()
587 .ok()
588 .and_then(|v| v.iter().map(|o| o.update_time.max(o.time)).max()),
589 orders.err().map(|e| e.to_string()),
590 trades.is_ok(),
591 trades.as_ref().map(|v| v.len()).unwrap_or(0),
592 trades
593 .as_ref()
594 .ok()
595 .and_then(|v| v.iter().map(|t| t.time).max()),
596 trades.err().map(|e| e.to_string()),
597 )
598 };
599
600 let mut errors = Vec::new();
601 if let Some(e) = orders_err {
602 errors.push(format!("allOrders failed: {}", e));
603 }
604 if let Some(e) = trades_err {
605 errors.push(format!("trades failed: {}", e));
606 }
607 let status = if orders_ok && trades_ok {
608 DoctorStatus::Ok
609 } else if orders_ok || trades_ok {
610 DoctorStatus::Warn
611 } else {
612 DoctorStatus::Fail
613 };
614 ReportOutcome {
615 status,
616 data: HistoryReport {
617 market: market.to_string(),
618 symbol,
619 all_orders_ok: orders_ok,
620 trades_ok,
621 all_orders_count: orders_count,
622 trades_count,
623 latest_order_ms,
624 latest_trade_ms,
625 fetch_latency_ms: started.elapsed().as_millis() as u64,
626 },
627 errors,
628 }
629}
630
631async fn run_sync_once(
632 client: &BinanceRestClient,
633 market: &str,
634 symbol: String,
635) -> ReportOutcome<SyncOnceReport> {
636 let started = Instant::now();
637 let spot_auth = client.get_account().await;
638 let futures_auth = client.get_futures_account().await;
639 let futures_positions = client.get_futures_position_risk().await;
640 let history = run_history(client, market, symbol.clone()).await;
641
642 let mut errors = Vec::new();
643 if let Err(e) = spot_auth.as_ref() {
644 errors.push(format!("spot auth failed: {}", e));
645 }
646 if let Err(e) = futures_auth.as_ref() {
647 errors.push(format!("futures auth failed: {}", e));
648 }
649 if let Err(e) = futures_positions.as_ref() {
650 errors.push(format!("futures positions failed: {}", e));
651 }
652 errors.extend(history.errors.clone());
653
654 let status = if errors.is_empty() {
655 DoctorStatus::Ok
656 } else if spot_auth.is_ok()
657 || futures_auth.is_ok()
658 || history.data.all_orders_ok
659 || history.data.trades_ok
660 {
661 DoctorStatus::Warn
662 } else {
663 DoctorStatus::Fail
664 };
665
666 let futures_positions_count = futures_positions
667 .as_ref()
668 .ok()
669 .map(|rows| {
670 rows.iter()
671 .filter(|p| p.position_amt.abs() > f64::EPSILON)
672 .count()
673 })
674 .unwrap_or(0);
675
676 ReportOutcome {
677 status,
678 data: SyncOnceReport {
679 market: market.to_string(),
680 symbol,
681 auth_spot_ok: spot_auth.is_ok(),
682 auth_futures_ok: futures_auth.is_ok(),
683 futures_positions_count,
684 history_all_orders_ok: history.data.all_orders_ok,
685 history_trades_ok: history.data.trades_ok,
686 history_all_orders_count: history.data.all_orders_count,
687 history_trades_count: history.data.trades_count,
688 total_latency_ms: started.elapsed().as_millis() as u64,
689 },
690 errors,
691 }
692}
693
694fn map_positions(
695 rows: Vec<BinanceFuturesPositionRisk>,
696 symbol_filter: Option<String>,
697) -> Vec<PositionRow> {
698 let mut out = Vec::new();
699 for row in rows {
700 if row.position_amt.abs() <= f64::EPSILON {
701 continue;
702 }
703 if let Some(filter) = symbol_filter.as_ref() {
704 if row.symbol.trim().to_ascii_uppercase() != *filter {
705 continue;
706 }
707 }
708 let (unrealized_final, selected_source) = resolve_unrealized_pnl(
709 row.unrealized_profit,
710 row.mark_price,
711 row.entry_price,
712 row.position_amt,
713 );
714 out.push(PositionRow {
715 symbol: format!("{} (FUT)", row.symbol.trim().to_ascii_uppercase()),
716 side: side_text(row.position_amt).to_string(),
717 qty_abs: row.position_amt.abs(),
718 qty_signed: row.position_amt,
719 entry_price: row.entry_price,
720 mark_price: row.mark_price,
721 unrealized_api: row.unrealized_profit,
722 unrealized_final,
723 selected_source: selected_source.to_string(),
724 });
725 }
726 out
727}
728
729pub fn resolve_unrealized_pnl(
730 api_unrealized: f64,
731 mark_price: f64,
732 entry_price: f64,
733 position_amt: f64,
734) -> (f64, &'static str) {
735 if api_unrealized.abs() > f64::EPSILON {
736 return (api_unrealized, "api_unRealizedProfit");
737 }
738 if mark_price > f64::EPSILON && entry_price > f64::EPSILON && position_amt.abs() > f64::EPSILON
739 {
740 return (
741 (mark_price - entry_price) * position_amt,
742 "fallback_mark_minus_entry_times_qty",
743 );
744 }
745 (0.0, "zero")
746}
747
748fn extract_error_code_message(err: &anyhow::Error) -> (Option<i64>, String) {
749 if let Some(AppError::BinanceApi { code, msg }) = err.downcast_ref::<AppError>() {
750 return (Some(*code), msg.clone());
751 }
752 (None, err.to_string())
753}
754
755fn side_text(qty_signed: f64) -> &'static str {
756 if qty_signed > 0.0 {
757 "BUY"
758 } else if qty_signed < 0.0 {
759 "SELL"
760 } else {
761 "-"
762 }
763}
764
765fn print_json_envelope<T: Serialize>(
766 command: &str,
767 status: DoctorStatus,
768 data: T,
769 errors: Vec<String>,
770) -> Result<()> {
771 let envelope = DoctorEnvelope {
772 status,
773 timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
774 command: command.to_string(),
775 data,
776 errors,
777 };
778 println!("{}", serde_json::to_string_pretty(&envelope)?);
779 Ok(())
780}
781
782fn print_auth_text(data: &AuthReport, errors: &[String]) {
783 println!("doctor auth");
784 println!(
785 "hosts: rest_base_url={} futures_rest_base_url={}",
786 data.rest_base_url, data.futures_rest_base_url
787 );
788 println!(
789 "credential_lens: spot_key={} spot_secret={} futures_key={} futures_secret={}",
790 data.spot_key_len, data.spot_secret_len, data.futures_key_len, data.futures_secret_len
791 );
792 println!("checks:");
793 for c in &data.checks {
794 if c.ok {
795 println!(" - {}: OK", c.endpoint);
796 } else {
797 println!(
798 " - {}: FAIL code={} msg={}",
799 c.endpoint,
800 c.code
801 .map(|v| v.to_string())
802 .unwrap_or_else(|| "n/a".to_string()),
803 c.message
804 );
805 }
806 }
807 if !errors.is_empty() {
808 println!("errors:");
809 for e in errors {
810 println!(" - {}", e);
811 }
812 }
813}
814
815fn print_positions_text(data: &PositionsReport, errors: &[String]) {
816 println!("doctor positions --market {}", data.market);
817 if let Some(s) = data.symbol_filter.as_ref() {
818 println!("symbol_filter: {}", s);
819 }
820 println!("count: {}", data.count);
821 println!("rows:");
822 for r in &data.rows {
823 println!(
824 " - {} side={} qty={:.6} entry={:.6} mark={:.6} api={:+.6} final={:+.6} source={}",
825 r.symbol,
826 r.side,
827 r.qty_signed,
828 r.entry_price,
829 r.mark_price,
830 r.unrealized_api,
831 r.unrealized_final,
832 r.selected_source
833 );
834 }
835 if !errors.is_empty() {
836 println!("errors:");
837 for e in errors {
838 println!(" - {}", e);
839 }
840 }
841}
842
843fn print_doctor_help() {
844 println!("sandbox-quant doctor");
845 println!();
846 println!("Usage:");
847 println!(" sandbox-quant doctor auth [--json]");
848 println!(" sandbox-quant doctor positions --market futures [--symbol BTCUSDT] [--json]");
849 println!(" sandbox-quant doctor pnl --market futures [--symbol BTCUSDT] [--json]");
850 println!(" sandbox-quant doctor history --market spot|futures [--symbol BTCUSDT] [--json]");
851 println!(
852 " sandbox-quant doctor sync --once --market spot|futures [--symbol BTCUSDT] [--json]"
853 );
854 println!(" sandbox-quant doctor help");
855 println!();
856 println!("Notes:");
857 println!(" - doctor commands are read-only diagnostics");
858 println!(" - --market supports spot/futures for history/sync, futures-only for positions/pnl");
859}
860
861fn print_history_text(data: &HistoryReport, errors: &[String]) {
862 println!(
863 "doctor history --market {} --symbol {}",
864 data.market, data.symbol
865 );
866 println!(
867 "allOrders: ok={} count={} latest_ms={}",
868 data.all_orders_ok,
869 data.all_orders_count,
870 data.latest_order_ms
871 .map(|v| v.to_string())
872 .unwrap_or_else(|| "-".to_string())
873 );
874 println!(
875 "trades: ok={} count={} latest_ms={}",
876 data.trades_ok,
877 data.trades_count,
878 data.latest_trade_ms
879 .map(|v| v.to_string())
880 .unwrap_or_else(|| "-".to_string())
881 );
882 println!("latency_ms={}", data.fetch_latency_ms);
883 if !errors.is_empty() {
884 println!("errors:");
885 for e in errors {
886 println!(" - {}", e);
887 }
888 }
889}
890
891fn print_sync_once_text(data: &SyncOnceReport, errors: &[String]) {
892 println!(
893 "doctor sync --once --market {} --symbol {}",
894 data.market, data.symbol
895 );
896 println!(
897 "auth: spot_ok={} futures_ok={}",
898 data.auth_spot_ok, data.auth_futures_ok
899 );
900 println!("futures_positions_count={}", data.futures_positions_count);
901 println!(
902 "history: allOrders_ok={} trades_ok={} allOrders_count={} trades_count={}",
903 data.history_all_orders_ok,
904 data.history_trades_ok,
905 data.history_all_orders_count,
906 data.history_trades_count
907 );
908 println!("total_latency_ms={}", data.total_latency_ms);
909 if !errors.is_empty() {
910 println!("errors:");
911 for e in errors {
912 println!(" - {}", e);
913 }
914 }
915}
916
917fn print_pnl_text(data: &PnlReport, errors: &[String]) {
918 println!("doctor pnl --market {}", data.market);
919 if let Some(s) = data.symbol_filter.as_ref() {
920 println!("symbol_filter: {}", s);
921 }
922 println!("count: {}", data.count);
923 println!("rows:");
924 for r in &data.rows {
925 println!(
926 " - {} side={} qty={:.6} entry={:.6} mark={:.6} api={:+.6} fallback={:+.6} final={:+.6} source={}",
927 r.symbol,
928 r.side,
929 r.qty_signed,
930 r.entry_price,
931 r.mark_price,
932 r.api_unrealized,
933 r.fallback_unrealized,
934 r.final_unrealized,
935 r.selected_source
936 );
937 }
938 if !errors.is_empty() {
939 println!("errors:");
940 for e in errors {
941 println!(" - {}", e);
942 }
943 }
944}