1use crate::display::terminal::{
7 check_fail, check_pass, kv_row, section_footer, section_header, separator,
8};
9use crate::error::Result;
10use crate::market::{VenueDescriptor, VenueRegistry};
11use clap::{Args, Subcommand};
12
13#[derive(Debug, Subcommand)]
28pub enum VenuesCommands {
29 List(ListArgs),
41
42 Schema(SchemaArgs),
54
55 Init(InitArgs),
67
68 Validate(ValidateArgs),
80}
81
82#[derive(Debug, Args)]
84pub struct ListArgs {
85 #[arg(short, long, default_value = "table")]
87 pub format: ListFormat,
88}
89
90#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
92pub enum ListFormat {
93 #[default]
95 Table,
96 Json,
98}
99
100#[derive(Debug, Args)]
102pub struct SchemaArgs {
103 #[arg(short, long, default_value = "text")]
105 pub format: SchemaFormat,
106}
107
108#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
110pub enum SchemaFormat {
111 #[default]
113 Text,
114 Json,
116}
117
118#[derive(Debug, Args)]
120pub struct InitArgs {
121 #[arg(long)]
123 pub force: bool,
124}
125
126#[derive(Debug, Args)]
128pub struct ValidateArgs {
129 pub file: std::path::PathBuf,
131}
132
133pub fn run(cmd: VenuesCommands) -> Result<()> {
135 match cmd {
136 VenuesCommands::List(args) => run_list(args),
137 VenuesCommands::Schema(args) => run_schema(args),
138 VenuesCommands::Init(args) => run_init(args),
139 VenuesCommands::Validate(args) => run_validate(args),
140 }
141}
142
143fn run_list(args: ListArgs) -> Result<()> {
148 let registry = VenueRegistry::load()?;
149
150 match args.format {
151 ListFormat::Table => {
152 println!("{}", section_header("Available Venues"));
153 for id in registry.list() {
154 if let Some(desc) = registry.get(id) {
155 let caps = desc.capability_names().join(", ");
156 println!("{}", kv_row(id, &caps));
157 }
158 }
159 println!("{}", separator());
160 let user_dir = VenueRegistry::user_venues_dir();
161 let user_count = count_user_venues(&user_dir);
162 let total = registry.len();
163 let built_in = total - user_count;
164 println!(
165 "{}",
166 kv_row(
167 "Loaded",
168 &format!(
169 "{} venues ({} built-in, {} user)",
170 total, built_in, user_count
171 )
172 )
173 );
174 println!("{}", kv_row("User dir", &user_dir.display().to_string()));
175 println!("{}", section_footer());
176 }
177 ListFormat::Json => {
178 let venues: Vec<serde_json::Value> = registry
179 .list()
180 .iter()
181 .filter_map(|id| {
182 registry.get(id).map(|desc| {
183 serde_json::json!({
184 "id": desc.id,
185 "name": desc.name,
186 "base_url": desc.base_url,
187 "capabilities": desc.capability_names(),
188 })
189 })
190 })
191 .collect();
192 let output = serde_json::json!({
193 "venues": venues,
194 "total": registry.len(),
195 "user_venues_dir": VenueRegistry::user_venues_dir().display().to_string(),
196 });
197 println!("{}", serde_json::to_string_pretty(&output).unwrap());
198 }
199 }
200
201 Ok(())
202}
203
204fn count_user_venues(dir: &std::path::Path) -> usize {
206 if !dir.exists() {
207 return 0;
208 }
209 std::fs::read_dir(dir)
210 .map(|entries| {
211 entries
212 .filter_map(|e| e.ok())
213 .filter(|e| {
214 e.path()
215 .extension()
216 .map(|ext| ext == "yaml" || ext == "yml")
217 .unwrap_or(false)
218 })
219 .count()
220 })
221 .unwrap_or(0)
222}
223
224fn run_schema(args: SchemaArgs) -> Result<()> {
229 match args.format {
230 SchemaFormat::Text => print_annotated_schema(),
231 SchemaFormat::Json => print_json_schema(),
232 }
233 Ok(())
234}
235
236fn print_annotated_schema() {
237 println!("{}", section_header("Venue Descriptor Schema"));
238 println!(
239 r#"
240A venue descriptor is a YAML file that tells Scope how to talk to
241an exchange API. Place custom descriptors in:
242
243 {}
244
245Each file defines one venue with the following structure:
246
247 id: my_exchange # Unique ID (lowercase, underscores ok)
248 name: My Exchange # Human-readable display name
249 base_url: https://api.example.com # API base URL
250
251 symbol:
252 template: "{{base}}{{quote}}" # Pair format (placeholders: {{base}}, {{quote}})
253 default_quote: USDT # Quote currency when user omits it
254 case: upper # "upper" or "lower" (default: upper)
255
256 capabilities:
257 order_book: # Omit if the venue has no depth API
258 method: GET # GET (default) or POST
259 path: /api/v1/depth # URL path appended to base_url
260 params: # Query parameters (GET) — values can use {{pair}}, {{limit}}
261 symbol: "{{pair}}"
262 limit: "{{limit}}"
263 response_root: data # JSON path to the relevant data (optional)
264 response:
265 asks_key: asks # JSON key for asks array
266 bids_key: bids # JSON key for bids array
267 level_format: positional # "positional" for [price, qty] or "object"
268 level_price_field: price # Only needed when level_format = object
269 level_size_field: size # Only needed when level_format = object
270
271 ticker: # Omit if the venue has no ticker API
272 path: /api/v1/ticker
273 params:
274 symbol: "{{pair}}"
275 response:
276 last_price: lastPrice
277 high_24h: highPrice
278 low_24h: lowPrice
279 volume_24h: volume
280 quote_volume_24h: quoteVolume
281 price_change_24h: priceChange
282 price_change_pct_24h: priceChangePercent
283
284 trades: # Omit if the venue has no trades API
285 path: /api/v1/trades
286 params:
287 symbol: "{{pair}}"
288 limit: "{{limit}}"
289 response:
290 items_key: data # JSON key containing the trades array (omit if root)
291 price: price # Field for trade price
292 quantity: qty # Field for trade quantity
293 timestamp_ms: time # Field for trade timestamp (epoch ms)
294 side: # Side detection
295 field: side # JSON field containing buy/sell indicator
296 mapping:
297 buy: buy
298 sell: sell
299
300Validate your file with: scope venues validate <file>
301"#,
302 VenueRegistry::user_venues_dir().display()
303 );
304 println!("{}", section_footer());
305}
306
307fn print_json_schema() {
308 use serde_json::{Map, Value};
309
310 fn str_prop(desc: &str) -> Value {
311 let mut m = Map::new();
312 m.insert("type".into(), Value::String("string".into()));
313 m.insert("description".into(), Value::String(desc.into()));
314 Value::Object(m)
315 }
316
317 fn str_type() -> Value {
318 serde_json::json!({"type": "string"})
319 }
320
321 let mut resp_props = Map::new();
323 for key in &[
324 "asks_key",
325 "bids_key",
326 "level_format",
327 "level_price_field",
328 "level_size_field",
329 "last_price",
330 "high_24h",
331 "low_24h",
332 "volume_24h",
333 "quote_volume_24h",
334 "best_bid",
335 "best_ask",
336 "items_key",
337 "price",
338 "quantity",
339 "quote_quantity",
340 "timestamp_ms",
341 "id",
342 ] {
343 resp_props.insert((*key).into(), str_type());
344 }
345 resp_props.insert(
346 "filter".into(),
347 serde_json::json!({
348 "type": "object",
349 "properties": {"field": {"type": "string"}, "value": {"type": "string"}}
350 }),
351 );
352 resp_props.insert(
353 "side".into(),
354 serde_json::json!({
355 "type": "object",
356 "properties": {
357 "field": {"type": "string"},
358 "mapping": {"type": "object", "additionalProperties": {"type": "string"}}
359 }
360 }),
361 );
362
363 let endpoint_def = serde_json::json!({
365 "type": "object",
366 "required": ["path", "response"],
367 "properties": {
368 "method": {"type": "string", "enum": ["GET", "POST"], "default": "GET"},
369 "path": {"type": "string"},
370 "params": {"type": "object", "additionalProperties": {"type": "string"}},
371 "request_body": {"description": "JSON body template for POST requests"},
372 "response_root": {"type": "string", "description": "Dot-path to navigate JSON response"},
373 "response": {"type": "object", "properties": Value::Object(resp_props)}
374 }
375 });
376
377 let endpoint_ref = serde_json::json!({"$ref": "#/$defs/endpoint"});
378
379 let schema = serde_json::json!({
380 "$schema": "https://json-schema.org/draft/2020-12/schema",
381 "title": "VenueDescriptor",
382 "description": "Schema for Scope exchange venue descriptor YAML files.",
383 "type": "object",
384 "required": ["id", "name", "base_url", "symbol", "capabilities"],
385 "properties": {
386 "id": str_prop("Unique venue identifier (e.g., 'binance')"),
387 "name": str_prop("Human-readable venue name (e.g., 'Binance Spot')"),
388 "base_url": str_prop("API base URL (e.g., 'https://api.binance.com')"),
389 "symbol": {
390 "type": "object",
391 "required": ["template", "default_quote"],
392 "properties": {
393 "template": str_prop("Pair template with {base} and {quote} placeholders"),
394 "default_quote": str_prop("Default quote currency (e.g., 'USDT')"),
395 "case": {"type": "string", "enum": ["upper", "lower"], "default": "upper"}
396 }
397 },
398 "capabilities": {
399 "type": "object",
400 "properties": {
401 "order_book": endpoint_ref.clone(),
402 "ticker": endpoint_ref.clone(),
403 "trades": endpoint_ref
404 }
405 }
406 },
407 "$defs": {
408 "endpoint": endpoint_def
409 }
410 });
411
412 println!("{}", serde_json::to_string_pretty(&schema).unwrap());
413}
414
415fn run_init(args: InitArgs) -> Result<()> {
420 run_init_impl(args, VenueRegistry::user_venues_dir())
421}
422
423fn run_init_impl(args: InitArgs, dest: std::path::PathBuf) -> Result<()> {
425 if !dest.exists() {
427 std::fs::create_dir_all(&dest).map_err(|e| {
428 crate::error::ScopeError::Chain(format!(
429 "Failed to create venues directory {}: {}",
430 dest.display(),
431 e
432 ))
433 })?;
434 println!("Created {}", dest.display());
435 }
436
437 let registry = VenueRegistry::load()?;
439 let mut copied = 0;
440 let mut skipped = 0;
441
442 for id in registry.list() {
443 let filename = format!("{}.yaml", id);
444 let target = dest.join(&filename);
445
446 if target.exists() && !args.force {
447 skipped += 1;
448 println!(" skip {} (exists, use --force to overwrite)", filename);
449 continue;
450 }
451
452 if let Some(desc) = registry.get(id) {
454 let yaml = format!(
456 "# {name} venue descriptor\n# Auto-generated by `scope venues init`\n\n{content}",
457 name = desc.name,
458 content = serialize_descriptor_yaml(desc),
459 );
460 std::fs::write(&target, yaml).map_err(|e| {
461 crate::error::ScopeError::Chain(format!(
462 "Failed to write {}: {}",
463 target.display(),
464 e
465 ))
466 })?;
467 copied += 1;
468 println!(" {}", check_pass(&filename));
469 }
470 }
471
472 println!();
473 println!("{}", section_header("Venues Init"));
474 println!("{}", kv_row("Directory", &dest.display().to_string()));
475 println!("{}", kv_row("Copied", &format!("{} files", copied)));
476 if skipped > 0 {
477 println!(
478 "{}",
479 kv_row("Skipped", &format!("{} files (already exist)", skipped))
480 );
481 }
482 println!("{}", section_footer());
483
484 Ok(())
485}
486
487fn serialize_descriptor_yaml(desc: &VenueDescriptor) -> String {
491 let mut lines = Vec::new();
493 lines.push(format!("id: {}", desc.id));
494 lines.push(format!("name: \"{}\"", desc.name));
495 lines.push(format!("base_url: \"{}\"", desc.base_url));
496
497 lines.push("symbol:".to_string());
498 lines.push(format!(" template: \"{}\"", desc.symbol.template));
499 lines.push(format!(" default_quote: {}", desc.symbol.default_quote));
500 let case_str = match desc.symbol.case {
501 crate::market::descriptor::SymbolCase::Upper => "upper",
502 crate::market::descriptor::SymbolCase::Lower => "lower",
503 };
504 lines.push(format!(" case: {}", case_str));
505
506 lines.push("capabilities:".to_string());
507
508 if let Some(ref ep) = desc.capabilities.order_book {
509 lines.push(" order_book:".to_string());
510 append_endpoint_yaml(&mut lines, ep, " ");
511 }
512 if let Some(ref ep) = desc.capabilities.ticker {
513 lines.push(" ticker:".to_string());
514 append_endpoint_yaml(&mut lines, ep, " ");
515 }
516 if let Some(ref ep) = desc.capabilities.trades {
517 lines.push(" trades:".to_string());
518 append_endpoint_yaml(&mut lines, ep, " ");
519 }
520
521 lines.join("\n")
522}
523
524fn append_endpoint_yaml(
525 lines: &mut Vec<String>,
526 ep: &crate::market::descriptor::EndpointDescriptor,
527 indent: &str,
528) {
529 let method = match ep.method {
530 crate::market::descriptor::HttpMethod::GET => "GET",
531 crate::market::descriptor::HttpMethod::POST => "POST",
532 };
533 if method != "GET" {
534 lines.push(format!("{indent}method: {method}"));
535 }
536 lines.push(format!("{indent}path: \"{}\"", ep.path));
537
538 if !ep.params.is_empty() {
539 lines.push(format!("{indent}params:"));
540 let mut params: Vec<_> = ep.params.iter().collect();
541 params.sort_by_key(|(k, _)| (*k).clone());
542 for (k, v) in params {
543 lines.push(format!("{indent} {k}: \"{v}\""));
544 }
545 }
546
547 if let Some(ref body) = ep.request_body {
548 lines.push(format!(
549 "{indent}request_body: {}",
550 serde_json::to_string(body).unwrap_or_default()
551 ));
552 }
553
554 if let Some(ref root) = ep.response_root {
555 lines.push(format!("{indent}response_root: \"{}\"", root));
556 }
557
558 lines.push(format!("{indent}response:"));
559 let r = &ep.response;
560 let resp_indent = format!("{indent} ");
561 if let Some(ref v) = r.asks_key {
562 lines.push(format!("{resp_indent}asks_key: {v}"));
563 }
564 if let Some(ref v) = r.bids_key {
565 lines.push(format!("{resp_indent}bids_key: {v}"));
566 }
567 if let Some(ref v) = r.level_format {
568 lines.push(format!("{resp_indent}level_format: {v}"));
569 }
570 if let Some(ref v) = r.level_price_field {
571 lines.push(format!("{resp_indent}level_price_field: {v}"));
572 }
573 if let Some(ref v) = r.level_size_field {
574 lines.push(format!("{resp_indent}level_size_field: {v}"));
575 }
576 if let Some(ref v) = r.last_price {
577 lines.push(format!("{resp_indent}last_price: {v}"));
578 }
579 if let Some(ref v) = r.high_24h {
580 lines.push(format!("{resp_indent}high_24h: {v}"));
581 }
582 if let Some(ref v) = r.low_24h {
583 lines.push(format!("{resp_indent}low_24h: {v}"));
584 }
585 if let Some(ref v) = r.volume_24h {
586 lines.push(format!("{resp_indent}volume_24h: {v}"));
587 }
588 if let Some(ref v) = r.quote_volume_24h {
589 lines.push(format!("{resp_indent}quote_volume_24h: {v}"));
590 }
591 if let Some(ref v) = r.best_bid {
592 lines.push(format!("{resp_indent}best_bid: {v}"));
593 }
594 if let Some(ref v) = r.best_ask {
595 lines.push(format!("{resp_indent}best_ask: {v}"));
596 }
597 if let Some(ref v) = r.items_key {
598 lines.push(format!("{resp_indent}items_key: {v}"));
599 }
600 if let Some(ref f) = r.filter {
601 lines.push(format!("{resp_indent}filter:"));
602 lines.push(format!("{resp_indent} field: \"{}\"", f.field));
603 lines.push(format!("{resp_indent} value: \"{}\"", f.value));
604 }
605 if let Some(ref v) = r.price {
606 lines.push(format!("{resp_indent}price: {v}"));
607 }
608 if let Some(ref v) = r.quantity {
609 lines.push(format!("{resp_indent}quantity: {v}"));
610 }
611 if let Some(ref v) = r.quote_quantity {
612 lines.push(format!("{resp_indent}quote_quantity: {v}"));
613 }
614 if let Some(ref v) = r.timestamp_ms {
615 lines.push(format!("{resp_indent}timestamp_ms: {v}"));
616 }
617 if let Some(ref v) = r.id {
618 lines.push(format!("{resp_indent}id: {v}"));
619 }
620 if let Some(ref sm) = r.side {
621 lines.push(format!("{resp_indent}side:"));
622 lines.push(format!("{resp_indent} field: \"{}\"", sm.field));
623 if !sm.mapping.is_empty() {
624 lines.push(format!("{resp_indent} mapping:"));
625 let mut entries: Vec<_> = sm.mapping.iter().collect();
626 entries.sort_by_key(|(k, _)| (*k).clone());
627 for (k, v) in entries {
628 lines.push(format!("{resp_indent} \"{k}\": \"{v}\""));
629 }
630 }
631 }
632}
633
634fn run_validate(args: ValidateArgs) -> Result<()> {
639 let path = &args.file;
640
641 if !path.exists() {
642 println!(
643 "{}",
644 check_fail(&format!("File not found: {}", path.display()))
645 );
646 return Err(crate::error::ScopeError::Chain(format!(
647 "File not found: {}",
648 path.display()
649 )));
650 }
651
652 let content = std::fs::read_to_string(path).map_err(|e| {
653 crate::error::ScopeError::Chain(format!("Failed to read {}: {}", path.display(), e))
654 })?;
655
656 println!("{}", section_header("Venue Validation"));
657 println!("{}", kv_row("File", &path.display().to_string()));
658 println!("{}", separator());
659
660 match VenueRegistry::validate_yaml(&content) {
661 Ok(desc) => {
662 println!("{}", check_pass("Valid YAML syntax"));
663 println!("{}", check_pass(&format!("Venue ID: {}", desc.id)));
664 println!("{}", check_pass(&format!("Name: {}", desc.name)));
665 println!("{}", check_pass(&format!("Base URL: {}", desc.base_url)));
666
667 let caps = desc.capability_names();
669 if caps.is_empty() {
670 println!(
671 "{}",
672 check_fail(
673 "No capabilities defined (need at least one of: order_book, ticker, trades)"
674 )
675 );
676 } else {
677 for cap in &caps {
678 println!("{}", check_pass(&format!("Capability: {}", cap)));
679 }
680 }
681
682 if desc.symbol.template.contains("{base}") {
684 println!(
685 "{}",
686 check_pass("Symbol template contains {base} placeholder")
687 );
688 } else {
689 println!(
690 "{}",
691 check_fail("Symbol template missing {base} placeholder")
692 );
693 }
694
695 println!("{}", separator());
696 if caps.is_empty() {
697 println!("{}", check_fail("Validation completed with warnings"));
698 } else {
699 println!("{}", check_pass("Validation passed"));
700 }
701 println!("{}", section_footer());
702 Ok(())
703 }
704 Err(e) => {
705 println!("{}", check_fail("Invalid YAML"));
706 println!("{}", check_fail(&format!("Error: {}", e)));
707 println!("{}", separator());
708 println!(
709 "{}",
710 kv_row(
711 "Hint",
712 "Run `scope venues schema` to see the expected format"
713 )
714 );
715 println!("{}", section_footer());
716 Err(e)
717 }
718 }
719}
720
721#[cfg(test)]
726mod tests {
727 use super::*;
728
729 #[test]
730 fn test_list_format_default() {
731 let fmt = ListFormat::default();
732 assert!(matches!(fmt, ListFormat::Table));
733 }
734
735 #[test]
736 fn test_schema_format_default() {
737 let fmt = SchemaFormat::default();
738 assert!(matches!(fmt, SchemaFormat::Text));
739 }
740
741 #[test]
742 fn test_run_list_table() {
743 let args = ListArgs {
744 format: ListFormat::Table,
745 };
746 let result = run_list(args);
747 assert!(result.is_ok());
748 }
749
750 #[test]
751 fn test_run_list_json() {
752 let args = ListArgs {
753 format: ListFormat::Json,
754 };
755 let result = run_list(args);
756 assert!(result.is_ok());
757 }
758
759 #[test]
760 fn test_run_schema_text() {
761 let args = SchemaArgs {
762 format: SchemaFormat::Text,
763 };
764 let result = run_schema(args);
765 assert!(result.is_ok());
766 }
767
768 #[test]
769 fn test_run_schema_json() {
770 let args = SchemaArgs {
771 format: SchemaFormat::Json,
772 };
773 let result = run_schema(args);
774 assert!(result.is_ok());
775 }
776
777 #[test]
778 fn test_validate_missing_file() {
779 let args = ValidateArgs {
780 file: std::path::PathBuf::from("/tmp/nonexistent_venue_test.yaml"),
781 };
782 let result = run_validate(args);
783 assert!(result.is_err());
784 }
785
786 #[test]
787 fn test_validate_valid_file() {
788 let yaml = r#"
789id: test_venue
790name: Test Exchange
791base_url: https://api.test.com
792symbol:
793 template: "{base}{quote}"
794 default_quote: USDT
795capabilities:
796 order_book:
797 path: /depth
798 params:
799 symbol: "{pair}"
800 response:
801 asks_key: asks
802 bids_key: bids
803 level_format: positional
804"#;
805 let dir = tempfile::tempdir().unwrap();
806 let path = dir.path().join("test.yaml");
807 std::fs::write(&path, yaml).unwrap();
808
809 let args = ValidateArgs { file: path };
810 let result = run_validate(args);
811 assert!(result.is_ok());
812 }
813
814 #[test]
815 fn test_validate_invalid_file() {
816 let yaml = "this is not valid yaml: [";
817 let dir = tempfile::tempdir().unwrap();
818 let path = dir.path().join("bad.yaml");
819 std::fs::write(&path, yaml).unwrap();
820
821 let args = ValidateArgs { file: path };
822 let result = run_validate(args);
823 assert!(result.is_err());
824 }
825
826 #[test]
827 fn test_count_user_venues_nonexistent() {
828 let count = count_user_venues(std::path::Path::new("/tmp/nonexistent_dir_test"));
829 assert_eq!(count, 0);
830 }
831
832 #[test]
833 fn test_count_user_venues_with_files() {
834 let dir = tempfile::tempdir().unwrap();
835 std::fs::write(dir.path().join("a.yaml"), "").unwrap();
836 std::fs::write(dir.path().join("b.yml"), "").unwrap();
837 std::fs::write(dir.path().join("c.txt"), "").unwrap();
838 assert_eq!(count_user_venues(dir.path()), 2);
839 }
840
841 #[test]
842 fn test_serialize_descriptor_yaml_roundtrip() {
843 let yaml = r#"
844id: roundtrip_test
845name: Roundtrip Exchange
846base_url: https://api.roundtrip.com
847symbol:
848 template: "{base}_{quote}"
849 default_quote: USDT
850 case: lower
851capabilities:
852 order_book:
853 path: /api/depth
854 params:
855 symbol: "{pair}"
856 response:
857 asks_key: asks
858 bids_key: bids
859 level_format: positional
860"#;
861 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
862 let serialized = serialize_descriptor_yaml(&desc);
863 assert!(serialized.contains("roundtrip_test"));
864 assert!(serialized.contains("Roundtrip Exchange"));
865 assert!(serialized.contains("/api/depth"));
866 assert!(serialized.contains("case: lower"));
867 }
868
869 #[test]
870 fn test_run_init_to_temp_dir() {
871 let dir = tempfile::tempdir().unwrap();
872 let dest = dir.path().to_path_buf();
873 let args = InitArgs { force: true };
874 let result = run_init_impl(args, dest.clone());
875 assert!(result.is_ok());
876
877 let registry = VenueRegistry::load().unwrap();
879 for id in registry.list() {
880 let filename = format!("{}.yaml", id);
881 let target = dest.join(&filename);
882 assert!(target.exists(), "Expected {} to exist", filename);
883 let content = std::fs::read_to_string(&target).unwrap();
884 assert!(content.contains(&format!("id: {}", id)));
885 }
886 }
887
888 #[test]
889 fn test_serialize_full_descriptor() {
890 let yaml = r#"
891id: full_caps
892name: Full Capabilities Exchange
893base_url: https://api.full.com
894symbol:
895 template: "{base}{quote}"
896 default_quote: USDT
897capabilities:
898 order_book:
899 path: /api/depth
900 params:
901 symbol: "{pair}"
902 response:
903 asks_key: asks
904 bids_key: bids
905 level_format: positional
906 ticker:
907 path: /api/ticker
908 params:
909 symbol: "{pair}"
910 response:
911 last_price: lastPrice
912 high_24h: high
913 low_24h: low
914 trades:
915 path: /api/trades
916 params:
917 symbol: "{pair}"
918 limit: "{limit}"
919 response:
920 items_key: data
921 price: price
922 quantity: qty
923 timestamp_ms: time
924 side:
925 field: side
926 mapping:
927 buy: buy
928 sell: sell
929"#;
930 let desc: VenueDescriptor = serde_yaml::from_str(yaml).unwrap();
931 let serialized = serialize_descriptor_yaml(&desc);
932 assert!(serialized.contains("order_book:"));
933 assert!(serialized.contains("ticker:"));
934 assert!(serialized.contains("trades:"));
935 assert!(serialized.contains("asks_key"));
936 assert!(serialized.contains("last_price"));
937 assert!(serialized.contains("items_key"));
938 }
939
940 #[test]
941 fn test_run_init_skips_existing() {
942 let dir = tempfile::tempdir().unwrap();
943 let dest = dir.path().to_path_buf();
944
945 let existing_path = dest.join("binance.yaml");
947 std::fs::create_dir_all(&dest).unwrap();
948 let original_content = "id: binance\n# pre-existing file\n";
949 std::fs::write(&existing_path, original_content).unwrap();
950
951 let args = InitArgs { force: false };
953 let result = run_init_impl(args, dest.clone());
954 assert!(result.is_ok());
955
956 let content = std::fs::read_to_string(&existing_path).unwrap();
958 assert_eq!(
959 content, original_content,
960 "Existing file should not be overwritten when force=false"
961 );
962 }
963}