use crate::panels::PanelKind;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::candle::Timeframe;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourceSpec {
Live {
venue: String,
symbol: String,
#[serde(default)]
testnet: bool,
#[serde(default)]
market: Market,
},
Replay {
dataset: String,
},
Synth {
seed: u64,
},
Manual,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Market {
#[default]
Spot,
UsdMFutures,
CoinMFutures,
Margin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RectSpec {
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
}
impl RectSpec {
#[must_use]
pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
Self { x, y, w, h }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelSpec {
pub kind: PanelKind,
pub rect: RectSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<usize>,
}
impl PanelSpec {
#[must_use]
pub fn new(kind: PanelKind, rect: RectSpec) -> Self {
Self {
kind,
rect,
depth: None,
}
}
#[must_use]
pub fn depth_or(&self, default: usize) -> usize {
self.depth.map_or(default, |d| d.clamp(1, MAX_PANEL_DEPTH))
}
}
pub const MAX_PANEL_DEPTH: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Keybinds {
pub bindings: HashMap<String, String>,
}
impl Default for Keybinds {
fn default() -> Self {
let bindings = [
("quit", "q"),
("next_panel", "tab"),
("prev_panel", "backtab"),
("source_menu", "s"),
("add_symbol", "a"),
("remove_symbol", "d"),
("remove_source", "x"),
("next_symbol", "right"),
("prev_symbol", "left"),
("add_indicator", "i"),
("remove_indicator", "k"),
("set_timeframe", "t"),
("list_indicators", "l"),
("seek_back", ","),
("seek_forward", "."),
("scroll_up", "up"),
("scroll_down", "down"),
("save_recording", "w"),
("set_recording", "r"),
("add_panel", "p"),
("remove_panel", "o"),
("move_panel", "m"),
]
.into_iter()
.map(|(a, k)| (a.to_string(), k.to_string()))
.collect();
Self { bindings }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IndicatorSpec {
pub kind: String,
#[serde(default)]
pub params: Vec<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
}
impl IndicatorSpec {
#[must_use]
pub fn new(kind: impl Into<String>, params: Vec<f64>) -> Self {
Self {
kind: kind.into(),
params,
reference: None,
}
}
#[must_use]
pub fn paired(kind: impl Into<String>, params: Vec<f64>, reference: impl Into<String>) -> Self {
Self {
kind: kind.into(),
params,
reference: Some(reference.into()),
}
}
#[must_use]
pub fn label(&self) -> String {
let base = if self.params.is_empty() {
self.kind.clone()
} else {
let params: Vec<String> = self
.params
.iter()
.map(|p| {
if p.fract() == 0.0 {
format!("{p:.0}")
} else {
p.to_string()
}
})
.collect();
format!("{}({})", self.kind, params.join(","))
};
match &self.reference {
Some(reference) => format!("{base} vs {reference}"),
None => base,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Layout {
pub panels: Vec<PanelSpec>,
#[serde(default)]
pub keybinds: Keybinds,
}
#[must_use]
pub fn default_indicators() -> Vec<IndicatorSpec> {
vec![
IndicatorSpec::new("Sma", vec![20.0]),
IndicatorSpec::new("Ema", vec![50.0]),
]
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub sources: Vec<SourceSpec>,
#[serde(default)]
pub layout: Layout,
#[serde(default = "default_indicators")]
pub indicators: Vec<IndicatorSpec>,
#[serde(default)]
pub timeframe: Timeframe,
#[serde(default)]
pub profiles: Vec<IndicatorSpec>,
#[serde(default)]
pub bars: Vec<IndicatorSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub record: Option<usize>,
#[serde(
default = "default_backfill",
skip_serializing_if = "is_default_backfill"
)]
pub backfill: usize,
}
fn default_backfill() -> usize {
200
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_default_backfill(value: &usize) -> bool {
*value == default_backfill()
}
pub const MAX_RECORDING: usize = 250_000;
impl Config {
pub fn from_toml(s: &str) -> Result<Self> {
toml::from_str(s).map_err(|e| Error::Config(e.to_string()))
}
pub fn from_json(s: &str) -> Result<Self> {
serde_json::from_str(s).map_err(|e| Error::Config(e.to_string()))
}
#[must_use]
pub fn default_layout() -> Self {
Self::default()
}
}
impl Default for Config {
fn default() -> Self {
Self {
sources: Vec::new(),
layout: Layout::default(),
indicators: default_indicators(),
timeframe: Timeframe::default(),
profiles: Vec::new(),
bars: Vec::new(),
record: None,
backfill: default_backfill(),
}
}
}
impl Default for Layout {
fn default() -> Self {
let panels = vec![
PanelSpec::new(PanelKind::Chart, RectSpec::new(0, 0, 70, 70)),
PanelSpec::new(PanelKind::Book, RectSpec::new(70, 0, 30, 35)),
PanelSpec::new(PanelKind::Footprint, RectSpec::new(70, 35, 30, 35)),
PanelSpec::new(PanelKind::Tape, RectSpec::new(70, 70, 30, 30)),
PanelSpec::new(PanelKind::Watchlist, RectSpec::new(0, 70, 70, 30)),
];
Self {
panels,
keybinds: Keybinds::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_layout_has_five_panels_and_default_keybinds() {
let cfg = Config::default_layout();
assert_eq!(cfg.layout.panels.len(), 5);
assert_eq!(cfg.sources, Vec::new());
assert_eq!(cfg.layout.keybinds.bindings.get("quit").unwrap(), "q");
}
#[test]
fn config_round_trips_through_json() {
let cfg = Config::default_layout();
let json = serde_json::to_string(&cfg).unwrap();
let back = Config::from_json(&json).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn source_spec_synth_parses_from_json() {
let cfg = Config::from_json(r#"{"sources":[{"Synth":{"seed":7}}],"layout":{"panels":[]}}"#)
.unwrap();
assert_eq!(cfg.sources, vec![SourceSpec::Synth { seed: 7 }]);
}
#[test]
fn source_spec_manual_parses_from_json() {
let cfg = Config::from_json(r#"{"sources":["Manual"],"layout":{"panels":[]}}"#).unwrap();
assert_eq!(cfg.sources, vec![SourceSpec::Manual]);
}
#[test]
fn malformed_toml_is_a_config_error() {
let err = Config::from_toml("not = = valid").unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn a_config_with_indicators_and_a_timeframe_round_trips_through_json() {
let mut cfg = Config::default_layout();
cfg.indicators = vec![
IndicatorSpec::new("Rsi", vec![14.0]),
IndicatorSpec::new("MacdIndicator", vec![12.0, 26.0, 9.0]),
IndicatorSpec::new("AdaptiveCycle", vec![]),
];
cfg.timeframe = Timeframe::parse("15m").unwrap();
let json = serde_json::to_string(&cfg).unwrap();
assert_eq!(Config::from_json(&json).unwrap(), cfg);
}
#[test]
fn a_config_round_trips_through_toml() {
let mut cfg = Config::default_layout();
cfg.sources = vec![SourceSpec::Synth { seed: 3 }];
cfg.indicators = vec![IndicatorSpec::new("Atr", vec![14.0])];
cfg.timeframe = Timeframe::parse("4h").unwrap();
let text = toml::to_string(&cfg).unwrap();
assert_eq!(Config::from_toml(&text).unwrap(), cfg);
}
#[test]
fn the_timeframe_survives_as_its_label_not_as_a_number() {
let mut cfg = Config::default_layout();
cfg.timeframe = Timeframe::parse("15m").unwrap();
let json = serde_json::to_string(&cfg).unwrap();
assert!(
json.contains(r#""timeframe":"15m""#),
"a config should carry the label a reader recognises: {json}"
);
}
#[test]
fn omitting_indicators_and_timeframe_yields_the_defaults() {
let cfg = Config::from_json(r#"{"sources":[],"layout":{"panels":[]}}"#).unwrap();
assert_eq!(cfg.indicators, default_indicators());
assert_eq!(cfg.timeframe, Timeframe::default());
}
#[test]
fn omitting_the_layout_yields_the_standard_panels() {
let cfg = Config::from_json(r#"{"sources":[{"Synth":{"seed":1}}]}"#).unwrap();
assert_eq!(cfg.layout.panels.len(), 5);
assert_eq!(cfg.sources, vec![SourceSpec::Synth { seed: 1 }]);
}
#[test]
fn an_empty_object_is_a_valid_config() {
let cfg = Config::from_json("{}").unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn an_indicator_spec_may_omit_its_parameters() {
let cfg = Config::from_json(r#"{"indicators":[{"kind":"AdaptiveCycle"}]}"#).unwrap();
assert_eq!(
cfg.indicators,
vec![IndicatorSpec::new("AdaptiveCycle", vec![])]
);
}
#[test]
fn an_invalid_timeframe_is_rejected_at_parse_time() {
let err = Config::from_json(r#"{"timeframe":"1w"}"#).unwrap_err();
assert!(matches!(err, Error::Config(_)), "{err}");
assert!(err.to_string().contains("1w"), "{err}");
}
#[test]
fn indicator_labels_render_whole_numbers_without_a_decimal_point() {
assert_eq!(IndicatorSpec::new("Sma", vec![20.0]).label(), "Sma(20)");
assert_eq!(
IndicatorSpec::new("MacdIndicator", vec![12.0, 26.0, 9.0]).label(),
"MacdIndicator(12,26,9)"
);
assert_eq!(
IndicatorSpec::new("AdaptiveCycle", vec![]).label(),
"AdaptiveCycle"
);
assert_eq!(
IndicatorSpec::new("AccelerationBands", vec![14.0, 2.5]).label(),
"AccelerationBands(14,2.5)"
);
}
#[test]
fn every_default_indicator_is_in_the_registry() {
for spec in default_indicators() {
assert!(
crate::registry::build(&spec.kind, &spec.params).is_ok(),
"the default overlay names {}, which the registry does not accept",
spec.kind
);
}
}
}