Skip to main content

sandbox_quant/
ui_docs.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{anyhow, bail, Context, Result};
5use ratatui::backend::TestBackend;
6use ratatui::Terminal;
7use serde::Deserialize;
8
9use crate::model::candle::Candle;
10use crate::ui::{self, AppState, GridTab};
11
12const DEFAULT_SCENARIO_DIR: &str = "docs/ui/scenarios";
13const DEFAULT_INDEX_PATH: &str = "docs/ui/INDEX.md";
14const DEFAULT_README_PATH: &str = "README.md";
15const DEFAULT_SYMBOLS: [&str; 5] = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"];
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct Scenario {
19    pub id: String,
20    pub title: String,
21    #[serde(default = "default_width")]
22    pub width: u16,
23    #[serde(default = "default_height")]
24    pub height: u16,
25    #[serde(default)]
26    pub profiles: Vec<String>,
27    #[serde(default, alias = "step")]
28    pub steps: Vec<Step>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum Step {
34    Key { value: String },
35    Wait { ms: u64 },
36    AssertText { value: String },
37    Snapshot { path: String },
38}
39
40#[derive(Debug, Clone)]
41pub struct RenderedScenario {
42    pub id: String,
43    pub title: String,
44    pub snapshot_paths: Vec<SnapshotArtifact>,
45}
46
47#[derive(Debug, Clone)]
48pub struct SnapshotArtifact {
49    pub raw_path: String,
50    pub image_path: Option<String>,
51}
52
53fn default_width() -> u16 {
54    180
55}
56
57fn default_height() -> u16 {
58    50
59}
60
61pub fn run_cli(args: &[String]) -> Result<()> {
62    if args.is_empty() {
63        return run_mode("full");
64    }
65    match args[0].as_str() {
66        "smoke" => run_mode("smoke"),
67        "full" => run_mode("full"),
68        "scenario" => {
69            let id = args
70                .get(1)
71                .ok_or_else(|| anyhow!("`scenario` requires an id argument"))?;
72            run_single_scenario(id)
73        }
74        "readme-only" => {
75            let rendered = collect_existing_rendered(DEFAULT_INDEX_PATH)?;
76            update_readme(DEFAULT_README_PATH, &rendered)
77        }
78        "help" | "--help" | "-h" => {
79            print_usage();
80            Ok(())
81        }
82        other => bail!(
83            "unknown subcommand `{}`. expected one of: smoke|full|scenario|readme-only",
84            other
85        ),
86    }
87}
88
89fn run_mode(profile: &str) -> Result<()> {
90    let scenarios = load_scenarios_from_dir(DEFAULT_SCENARIO_DIR)?;
91    let filtered: Vec<Scenario> = if profile == "full" {
92        scenarios
93    } else {
94        scenarios
95            .into_iter()
96            .filter(|s| s.profiles.iter().any(|p| p == profile))
97            .collect()
98    };
99    if filtered.is_empty() {
100        bail!("no scenarios found for profile `{}`", profile);
101    }
102    run_scenarios_and_write(&filtered, DEFAULT_INDEX_PATH, DEFAULT_README_PATH)?;
103    Ok(())
104}
105
106fn run_single_scenario(id: &str) -> Result<()> {
107    let scenarios = load_scenarios_from_dir(DEFAULT_SCENARIO_DIR)?;
108    let scenario = scenarios
109        .into_iter()
110        .find(|s| s.id == id)
111        .ok_or_else(|| anyhow!("scenario `{}` not found", id))?;
112    run_scenarios_and_write(&[scenario], DEFAULT_INDEX_PATH, DEFAULT_README_PATH)?;
113    Ok(())
114}
115
116pub fn run_scenarios_and_write<P: AsRef<Path>, R: AsRef<Path>>(
117    scenarios: &[Scenario],
118    index_path: P,
119    readme_path: R,
120) -> Result<Vec<RenderedScenario>> {
121    let rendered = run_scenarios(scenarios)?;
122    write_index(index_path, &rendered)?;
123    update_readme(readme_path, &rendered)?;
124    Ok(rendered)
125}
126
127pub fn load_scenarios_from_dir<P: AsRef<Path>>(dir: P) -> Result<Vec<Scenario>> {
128    let mut paths: Vec<PathBuf> = fs::read_dir(dir.as_ref())
129        .with_context(|| format!("failed to read {}", dir.as_ref().display()))?
130        .filter_map(|entry| entry.ok().map(|e| e.path()))
131        .filter(|path| path.extension().map(|ext| ext == "toml").unwrap_or(false))
132        .collect();
133    paths.sort();
134
135    let mut scenarios = Vec::with_capacity(paths.len());
136    for path in paths {
137        let raw = fs::read_to_string(&path)
138            .with_context(|| format!("failed to read scenario {}", path.display()))?;
139        let scenario: Scenario = toml::from_str(&raw)
140            .with_context(|| format!("failed to parse scenario {}", path.display()))?;
141        scenarios.push(scenario);
142    }
143    Ok(scenarios)
144}
145
146fn run_scenarios(scenarios: &[Scenario]) -> Result<Vec<RenderedScenario>> {
147    scenarios.iter().map(run_scenario).collect()
148}
149
150fn run_scenario(s: &Scenario) -> Result<RenderedScenario> {
151    let mut state = seed_state();
152    let mut snapshots = Vec::new();
153
154    for step in &s.steps {
155        match step {
156            Step::Key { value } => apply_key_action(&mut state, value)?,
157            Step::Wait { ms } => {
158                let _ = ms;
159            }
160            Step::AssertText { value } => {
161                let text = render_to_text(&state, s.width, s.height)?;
162                if !text.contains(value) {
163                    bail!(
164                        "scenario `{}` assert_text failed: missing `{}`",
165                        s.id,
166                        value
167                    );
168                }
169            }
170            Step::Snapshot { path } => {
171                let text = render_to_text(&state, s.width, s.height)?;
172                let snapshot_path = PathBuf::from(path);
173                if let Some(parent) = snapshot_path.parent() {
174                    fs::create_dir_all(parent).with_context(|| {
175                        format!("failed to create snapshot dir {}", parent.display())
176                    })?;
177                }
178                fs::write(&snapshot_path, text).with_context(|| {
179                    format!("failed to write snapshot {}", snapshot_path.display())
180                })?;
181                let image_path = write_svg_preview(&snapshot_path)?;
182                snapshots.push(SnapshotArtifact {
183                    raw_path: snapshot_path.to_string_lossy().to_string(),
184                    image_path,
185                });
186            }
187        }
188    }
189
190    if snapshots.is_empty() {
191        let default_path = format!("docs/ui/screenshots/{}.txt", s.id);
192        let text = render_to_text(&state, s.width, s.height)?;
193        let default_path_buf = PathBuf::from(&default_path);
194        if let Some(parent) = default_path_buf.parent() {
195            fs::create_dir_all(parent)
196                .with_context(|| format!("failed to create {}", parent.display()))?;
197        }
198        fs::write(&default_path_buf, text)
199            .with_context(|| format!("failed to write {}", default_path_buf.display()))?;
200        let image_path = write_svg_preview(&default_path_buf)?;
201        snapshots.push(SnapshotArtifact {
202            raw_path: default_path,
203            image_path,
204        });
205    }
206
207    Ok(RenderedScenario {
208        id: s.id.clone(),
209        title: s.title.clone(),
210        snapshot_paths: snapshots,
211    })
212}
213
214pub fn render_to_text(state: &AppState, width: u16, height: u16) -> Result<String> {
215    let backend = TestBackend::new(width, height);
216    let mut terminal = Terminal::new(backend).context("failed to init test terminal")?;
217    terminal
218        .draw(|frame| ui::render(frame, state))
219        .context("failed to render frame")?;
220    let buf = terminal.backend().buffer();
221    let area = buf.area;
222    let mut out = String::new();
223    for y in 0..area.height {
224        for x in 0..area.width {
225            out.push_str(buf[(x, y)].symbol());
226        }
227        out.push('\n');
228    }
229    Ok(out)
230}
231
232pub fn seed_state() -> AppState {
233    let mut state = AppState::new("BTCUSDT", "MA(Config)", 120, 60_000, "1m");
234    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
235    state.ws_connected = true;
236    state.current_equity_usdt = Some(10_000.0);
237    state.initial_equity_usdt = Some(9_800.0);
238    state.candles = seed_candles(now_ms, state.candle_interval_ms, 100, 67_000.0);
239    state.last_price_update_ms = Some(now_ms);
240    state.last_price_event_ms = Some(now_ms.saturating_sub(180));
241    state.last_price_latency_ms = Some(180);
242    state.last_order_history_update_ms = Some(now_ms.saturating_sub(1_100));
243    state.last_order_history_event_ms = Some(now_ms.saturating_sub(1_950));
244    state.last_order_history_latency_ms = Some(850);
245    state.symbol_items = DEFAULT_SYMBOLS.iter().map(|v| v.to_string()).collect();
246    state.strategy_item_symbols = vec![
247        "BTCUSDT".to_string(),
248        "ETHUSDT".to_string(),
249        "SOLUSDT".to_string(),
250    ];
251    state.strategy_item_active = vec![true, false, true];
252    state.strategy_item_total_running_ms = vec![3_600_000, 0, 7_200_000];
253    state.network_reconnect_count = 1;
254    state.network_tick_drop_count = 2;
255    state.network_tick_latencies_ms = vec![120, 160, 170, 210, 300];
256    state.network_fill_latencies_ms = vec![400, 600, 1200];
257    state.network_order_sync_latencies_ms = vec![100, 130, 170];
258    state.network_tick_in_timestamps_ms = vec![
259        now_ms.saturating_sub(200),
260        now_ms.saturating_sub(450),
261        now_ms.saturating_sub(920),
262        now_ms.saturating_sub(1_800),
263        now_ms.saturating_sub(8_000),
264    ];
265    state.network_tick_drop_timestamps_ms = vec![
266        now_ms.saturating_sub(600),
267        now_ms.saturating_sub(9_500),
268    ];
269    state.network_reconnect_timestamps_ms = vec![now_ms.saturating_sub(16_000)];
270    state.network_disconnect_timestamps_ms = vec![now_ms.saturating_sub(15_500)];
271    state.network_last_fill_ms = Some(now_ms.saturating_sub(4_500));
272    state.fast_sma = state.candles.last().map(|c| c.close * 0.9992);
273    state.slow_sma = state.candles.last().map(|c| c.close * 0.9985);
274    state
275}
276
277fn seed_candles(now_ms: u64, interval_ms: u64, count: usize, base_price: f64) -> Vec<Candle> {
278    let count = count.max(8);
279    let bucket_close = now_ms - (now_ms % interval_ms);
280    let mut candles = Vec::with_capacity(count);
281    for i in 0..count {
282        let remaining = (count - i) as u64;
283        let open_time = bucket_close.saturating_sub(remaining * interval_ms);
284        let close_time = open_time.saturating_add(interval_ms);
285        let drift = (i as f64) * 2.1;
286        let wave = ((i as f64) * 0.24).sin() * 18.0;
287        let open = base_price + drift + wave;
288        let close = open + (((i % 6) as f64) - 2.0) * 1.7;
289        let high = open.max(close) + 6.5;
290        let low = open.min(close) - 6.0;
291        candles.push(Candle {
292            open,
293            high,
294            low,
295            close,
296            open_time,
297            close_time,
298        });
299    }
300    candles
301}
302
303fn apply_key_action(state: &mut AppState, key: &str) -> Result<()> {
304    match key.to_ascii_lowercase().as_str() {
305        "g" => {
306            state.grid_open = !state.grid_open;
307            if !state.grid_open {
308                state.strategy_editor_open = false;
309            }
310        }
311        "1" => {
312            if state.grid_open {
313                state.grid_tab = GridTab::Assets;
314            }
315        }
316        "2" => {
317            if state.grid_open {
318                state.grid_tab = GridTab::Strategies;
319            }
320        }
321        "3" => {
322            if state.grid_open {
323                state.grid_tab = GridTab::Risk;
324            }
325        }
326        "4" => {
327            if state.grid_open {
328                state.grid_tab = GridTab::Network;
329            }
330        }
331        "5" => {
332            if state.grid_open {
333                state.grid_tab = GridTab::History;
334            }
335        }
336        "6" => {
337            if state.grid_open {
338                state.grid_tab = GridTab::SystemLog;
339            }
340        }
341        "tab" => {
342            if state.grid_open && state.grid_tab == GridTab::Strategies {
343                state.grid_select_on_panel = !state.grid_select_on_panel;
344            }
345        }
346        "c" => {
347            if state.grid_open && state.grid_tab == GridTab::Strategies {
348                state.strategy_editor_open = true;
349            }
350        }
351        "esc" => {
352            if state.strategy_editor_open {
353                state.strategy_editor_open = false;
354            } else if state.grid_open {
355                state.grid_open = false;
356            } else if state.symbol_selector_open {
357                state.symbol_selector_open = false;
358            } else if state.strategy_selector_open {
359                state.strategy_selector_open = false;
360            } else if state.account_popup_open {
361                state.account_popup_open = false;
362            } else if state.history_popup_open {
363                state.history_popup_open = false;
364            }
365        }
366        "t" => {
367            if !state.grid_open {
368                state.symbol_selector_open = true;
369            }
370        }
371        "y" => {
372            if !state.grid_open {
373                state.strategy_selector_open = true;
374            }
375        }
376        "a" => {
377            if !state.grid_open {
378                state.account_popup_open = true;
379            }
380        }
381        "i" => {
382            if !state.grid_open {
383                state.history_popup_open = true;
384            }
385        }
386        other => bail!("unsupported key action `{}`", other),
387    }
388    Ok(())
389}
390
391fn write_index<P: AsRef<Path>>(path: P, rendered: &[RenderedScenario]) -> Result<()> {
392    if let Some(parent) = path.as_ref().parent() {
393        fs::create_dir_all(parent)
394            .with_context(|| format!("failed to create {}", parent.display()))?;
395    }
396    let mut out = String::new();
397    out.push_str("# UI Snapshot Index\n\n");
398    out.push_str("Generated by `cargo run --bin ui_docs -- <mode>`.\n\n");
399    for item in rendered {
400        out.push_str(&format!("## {} (`{}`)\n\n", item.title, item.id));
401        for snapshot in &item.snapshot_paths {
402            if let Some(image_path) = &snapshot.image_path {
403                let rel_image = image_path
404                    .strip_prefix("docs/ui/")
405                    .unwrap_or(image_path.as_str());
406                out.push_str(&format!("![{}]({})\n\n", item.id, xml_escape(rel_image)));
407            }
408            out.push_str(&format!("- raw: `{}`\n", snapshot.raw_path));
409        }
410        out.push('\n');
411    }
412    fs::write(path.as_ref(), out)
413        .with_context(|| format!("failed to write {}", path.as_ref().display()))?;
414    Ok(())
415}
416
417fn collect_existing_rendered<P: AsRef<Path>>(index_path: P) -> Result<Vec<RenderedScenario>> {
418    let raw = fs::read_to_string(index_path.as_ref())
419        .with_context(|| format!("failed to read {}", index_path.as_ref().display()))?;
420    let mut rendered = Vec::new();
421    let mut current: Option<RenderedScenario> = None;
422
423    for line in raw.lines() {
424        if let Some(rest) = line.strip_prefix("## ") {
425            if let Some(prev) = current.take() {
426                rendered.push(prev);
427            }
428            let (title, id) = if let Some((lhs, rhs)) = rest.rsplit_once(" (`") {
429                let id = rhs.trim_end_matches("`)");
430                (lhs.trim().to_string(), id.to_string())
431            } else {
432                (rest.to_string(), "unknown".to_string())
433            };
434            current = Some(RenderedScenario {
435                id,
436                title,
437                snapshot_paths: Vec::new(),
438            });
439        } else if let Some(path) = line
440            .trim()
441            .strip_prefix("- raw: `")
442            .and_then(|v| v.strip_suffix('`'))
443        {
444            if let Some(curr) = current.as_mut() {
445                let image_path = infer_svg_path(Path::new(path));
446                curr.snapshot_paths.push(SnapshotArtifact {
447                    raw_path: path.to_string(),
448                    image_path,
449                });
450            }
451        }
452    }
453    if let Some(prev) = current.take() {
454        rendered.push(prev);
455    }
456    Ok(rendered)
457}
458
459pub fn update_readme<P: AsRef<Path>>(readme_path: P, rendered: &[RenderedScenario]) -> Result<()> {
460    let start_marker = "<!-- UI_DOCS:START -->";
461    let end_marker = "<!-- UI_DOCS:END -->";
462    let raw = fs::read_to_string(readme_path.as_ref())
463        .with_context(|| format!("failed to read {}", readme_path.as_ref().display()))?;
464    let start = raw
465        .find(start_marker)
466        .ok_or_else(|| anyhow!("README start marker not found"))?;
467    let end = raw
468        .find(end_marker)
469        .ok_or_else(|| anyhow!("README end marker not found"))?;
470    if start >= end {
471        bail!("README marker order invalid");
472    }
473    let mut block = String::new();
474    block.push_str(start_marker);
475    block.push('\n');
476    block.push_str("### UI Docs (Auto)\n\n");
477    block.push_str("- Generated by `cargo run --bin ui_docs -- smoke|full`\n");
478    block.push_str("- Full index: `docs/ui/INDEX.md`\n\n");
479    for item in rendered.iter().take(4) {
480        if let Some(snapshot) = item.snapshot_paths.first() {
481            if let Some(image_path) = &snapshot.image_path {
482                block.push_str(&format!(
483                    "![{}]({})\n\n",
484                    item.title,
485                    xml_escape(image_path)
486                ));
487            }
488            block.push_str(&format!("- {} raw: `{}`\n", item.title, snapshot.raw_path));
489        }
490    }
491    block.push('\n');
492    block.push_str(end_marker);
493    let next = format!(
494        "{}{}{}",
495        &raw[..start],
496        block,
497        &raw[end + end_marker.len()..]
498    );
499    fs::write(readme_path.as_ref(), next)
500        .with_context(|| format!("failed to write {}", readme_path.as_ref().display()))?;
501    Ok(())
502}
503
504fn print_usage() {
505    eprintln!("usage:");
506    eprintln!("  cargo run --bin ui-docs");
507    eprintln!("  cargo run --bin ui_docs -- smoke");
508    eprintln!("  cargo run --bin ui_docs -- full");
509    eprintln!("  cargo run --bin ui_docs -- scenario <id>");
510    eprintln!("  cargo run --bin ui_docs -- readme-only");
511}
512
513fn write_svg_preview(raw_snapshot_path: &Path) -> Result<Option<String>> {
514    let raw = fs::read_to_string(raw_snapshot_path)
515        .with_context(|| format!("failed to read {}", raw_snapshot_path.display()))?;
516    let svg_path = raw_snapshot_path.with_extension("svg");
517    let lines: Vec<&str> = raw.lines().collect();
518    let width_chars = lines
519        .iter()
520        .map(|line| line.chars().count())
521        .max()
522        .unwrap_or(0);
523    let height_chars = lines.len();
524    if width_chars == 0 || height_chars == 0 {
525        return Ok(None);
526    }
527    let cell_w = 9usize;
528    let cell_h = 18usize;
529    let px_w = (width_chars * cell_w + 24) as u32;
530    let px_h = (height_chars * cell_h + 24) as u32;
531
532    let mut svg = String::new();
533    svg.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
534    svg.push('\n');
535    svg.push_str(&format!(
536        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
537        px_w, px_h, px_w, px_h
538    ));
539    svg.push('\n');
540    svg.push_str(&format!(
541        r##"<rect x="0" y="0" width="{}" height="{}" fill="#0f111a"/>"##,
542        px_w, px_h
543    ));
544    svg.push('\n');
545    svg.push_str(r##"<g font-family="Menlo, Monaco, 'Courier New', monospace" font-size="14" fill="#d8dee9">"##);
546    svg.push('\n');
547
548    for (i, line) in lines.iter().enumerate() {
549        let y = 18 + (i as u32) * (cell_h as u32);
550        svg.push_str(&format!(
551            r#"<text x="12" y="{}" xml:space="preserve">{}</text>"#,
552            y,
553            xml_escape(line)
554        ));
555        svg.push('\n');
556    }
557    svg.push_str("</g>\n</svg>\n");
558
559    fs::write(&svg_path, svg).with_context(|| format!("failed to write {}", svg_path.display()))?;
560    Ok(Some(svg_path.to_string_lossy().to_string()))
561}
562
563fn infer_svg_path(raw_path: &Path) -> Option<String> {
564    let svg = raw_path.with_extension("svg");
565    if svg.exists() {
566        Some(svg.to_string_lossy().to_string())
567    } else {
568        None
569    }
570}
571
572fn xml_escape(input: &str) -> String {
573    input
574        .replace('&', "&amp;")
575        .replace('<', "&lt;")
576        .replace('>', "&gt;")
577        .replace('"', "&quot;")
578        .replace('\'', "&apos;")
579}