Skip to main content

sandbox_quant/
doctor.rs

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}