1use crate::drawings::{ChartPoint, Drawing, DrawingToolType, ParseToolTypeError, PositionSize};
41use serde::de::IntoDeserializer;
42use serde::{Deserialize, Serialize};
43use std::collections::HashSet;
44use std::fmt;
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct StoredCoord {
53 pub ts_ms: i64,
55 #[serde(default, skip_serializing_if = "is_zero")]
57 pub subms_ns: u32,
58 pub price: f64,
60}
61
62impl StoredCoord {
63 pub fn new(ts_ms: i64, price: f64) -> Self {
65 Self {
66 ts_ms,
67 subms_ns: 0,
68 price,
69 }
70 }
71
72 pub(crate) fn from_time(time: chrono::DateTime<chrono::Utc>, price: f64) -> Self {
73 Self {
74 ts_ms: time.timestamp_millis(),
75 subms_ns: time.timestamp_subsec_nanos() % 1_000_000,
76 price,
77 }
78 }
79
80 pub(crate) fn time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
81 if self.subms_ns >= 1_000_000 {
82 return None;
83 }
84 chrono::DateTime::from_timestamp_millis(self.ts_ms)?
85 .checked_add_signed(chrono::Duration::nanoseconds(i64::from(self.subms_ns)))
86 }
87}
88
89fn is_zero(value: &u32) -> bool {
90 *value == 0
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct StoredDrawing {
101 pub id: String,
103 pub points: Vec<StoredCoord>,
105 pub drawing: Drawing,
107 pub created_at: i64,
109 pub modified_at: i64,
111 pub notes: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub pane: Option<StoredPane>,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct StoredPane {
124 pub studies: Vec<(String, Vec<(String, serde_json::Value)>)>,
127 pub rank: usize,
129}
130
131impl StoredDrawing {
132 pub fn new(id: impl Into<String>, drawing: Drawing) -> Self {
134 let now = chrono::Utc::now().timestamp_millis();
135 Self {
136 id: id.into(),
137 points: Vec::new(),
138 drawing,
139 created_at: now,
140 modified_at: now,
141 notes: None,
142 pane: None,
143 }
144 }
145
146 pub fn add_point(&mut self, point: StoredCoord) {
148 self.points.push(point);
149 self.modified_at = chrono::Utc::now().timestamp_millis();
150 }
151
152 fn missing_anchors(&self) -> Option<(usize, usize)> {
154 let needed = self.drawing.tool_type.anchors_needed();
155 (self.points.len() < needed).then_some((needed, self.points.len()))
156 }
157
158 pub(super) fn invalid_field(&self) -> Option<String> {
161 use super::validation::{MAX_IMAGE_SIDE, MAX_PITCHFORK_LEVEL, MAX_STROKE_WIDTH};
162 use crate::styles::typography::FONT_SIZES;
163 let d = &self.drawing;
164 let bad = |value: f64| !value.is_finite();
165 if !(d.stroke_width > 0.0 && d.stroke_width <= MAX_STROKE_WIDTH) {
166 return Some("stroke_width".into());
167 }
168 if let Some(i) = d
169 .pitchfork
170 .iter()
171 .flat_map(|c| &c.levels)
172 .position(|l| l.width.is_some_and(|w| !(w > 0.0 && w <= MAX_STROKE_WIDTH)))
173 {
174 return Some(format!("pitchfork.levels[{i}].width"));
175 }
176 if d.screen_anchor
177 .is_some_and(|anchor| anchor.into_iter().any(|value| !value.is_finite()))
178 {
179 return Some("screen_anchor".into());
180 }
181 if d.image.and_then(|c| c.size).is_some_and(|size| {
182 size.into_iter()
183 .any(|side| !(1.0..=MAX_IMAGE_SIDE).contains(&side))
184 }) {
185 return Some("image.size".into());
186 }
187 if !FONT_SIZES.contains(&d.font_size) {
188 return Some("font_size".into());
189 }
190 if let Some(i) = self.points.iter().position(|p| bad(p.price)) {
191 return Some(format!("points[{i}].price"));
192 }
193 if d.ghost_feed.is_some_and(|c| !c.is_valid()) {
194 return Some("ghost_feed".into());
195 }
196 let sizing = d.position_sizing;
197 if [
198 sizing.point_value,
199 sizing.lot_size,
200 sizing.quantity_step,
201 sizing.leverage,
202 ]
203 .into_iter()
204 .any(bad)
205 {
206 return Some("position_sizing".into());
207 }
208 let size = match d.position_size {
209 Some(PositionSize::Quantity(value) | PositionSize::RiskAmount(value)) => [value, 0.0],
210 Some(PositionSize::RiskPercent {
211 account_size,
212 percent,
213 }) => [account_size, percent],
214 None => [0.0; 2],
215 };
216 if size.into_iter().any(bad) {
217 return Some("position_size".into());
218 }
219 if d.fib_config.as_ref().is_some_and(|c| {
220 !c.level_width.is_finite()
221 || c.level_width <= 0.0
222 || c.level_width > super::validation::MAX_STROKE_WIDTH
223 }) {
224 return Some("fib_config.level_width".into());
225 }
226 for (name, values) in [
227 (
228 "fib_config.levels",
229 d.fib_config
230 .iter()
231 .flat_map(|c| c.levels.iter().map(|l| l.value))
232 .collect::<Vec<_>>(),
233 ),
234 (
235 "pitchfork.levels",
236 d.pitchfork
237 .iter()
238 .flat_map(|c| c.levels.iter().map(|l| l.value))
239 .collect(),
240 ),
241 (
242 "gann.price_levels",
243 d.gann
244 .iter()
245 .flat_map(|c| c.price_levels.iter().map(|l| l.value))
246 .collect(),
247 ),
248 (
249 "gann.time_levels",
250 d.gann
251 .iter()
252 .flat_map(|c| c.time_levels.iter().map(|l| l.value))
253 .collect(),
254 ),
255 (
256 "gann.fan_ratios",
257 d.gann
258 .iter()
259 .flat_map(|c| c.fan_ratios.iter().map(|l| l.value))
260 .collect(),
261 ),
262 (
263 "gann.arc_ratios",
264 d.gann
265 .iter()
266 .flat_map(|c| c.arc_ratios.iter().map(|l| l.value))
267 .collect(),
268 ),
269 ] {
270 let limit = if name == "pitchfork.levels" {
272 MAX_PITCHFORK_LEVEL
273 } else {
274 f32::MAX
275 };
276 if let Some(i) = values.iter().position(|v| !(-limit..=limit).contains(v)) {
277 return Some(format!("{name}[{i}].value"));
278 }
279 }
280 None
281 }
282}
283
284#[derive(Debug, Clone, Serialize)]
289pub struct DrawingStorage {
290 pub version: u32,
292 pub symbol: String,
294 pub timeframe: Option<String>,
296 pub drawings: Vec<StoredDrawing>,
298 pub metadata: StorageMetadata,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct StorageMetadata {
305 pub created_at: i64,
307 pub modified_at: i64,
309 pub app_version: Option<String>,
311}
312
313impl Default for StorageMetadata {
314 fn default() -> Self {
315 let now = chrono::Utc::now().timestamp_millis();
316 Self {
317 created_at: now,
318 modified_at: now,
319 app_version: None,
320 }
321 }
322}
323
324impl DrawingStorage {
325 pub const CURRENT_VERSION: u32 = 6;
334
335 pub fn new(symbol: impl Into<String>) -> Self {
337 Self {
338 version: Self::CURRENT_VERSION,
339 symbol: symbol.into(),
340 timeframe: None,
341 drawings: Vec::new(),
342 metadata: StorageMetadata::default(),
343 }
344 }
345
346 pub fn with_timeframe(mut self, timeframe: impl Into<String>) -> Self {
348 self.timeframe = Some(timeframe.into());
349 self
350 }
351
352 pub fn add_drawing(&mut self, drawing: StoredDrawing) {
358 match self.get_drawing_mut(&drawing.id) {
359 Some(existing) => {
360 *existing = StoredDrawing {
361 created_at: existing.created_at,
362 ..drawing
363 }
364 }
365 None => self.drawings.push(drawing),
366 }
367 self.metadata.modified_at = chrono::Utc::now().timestamp_millis();
368 }
369
370 pub fn remove_drawing(&mut self, id: &str) -> bool {
372 let len_before = self.drawings.len();
373 self.drawings.retain(|d| d.id != id);
374 if self.drawings.len() != len_before {
375 self.metadata.modified_at = chrono::Utc::now().timestamp_millis();
376 true
377 } else {
378 false
379 }
380 }
381
382 pub fn get_drawing(&self, id: &str) -> Option<&StoredDrawing> {
384 self.drawings.iter().find(|d| d.id == id)
385 }
386
387 pub fn get_drawing_mut(&mut self, id: &str) -> Option<&mut StoredDrawing> {
389 self.drawings.iter_mut().find(|d| d.id == id)
390 }
391
392 pub fn drawings(&self) -> &[StoredDrawing] {
394 &self.drawings
395 }
396
397 pub fn to_json(&self) -> Result<String, SaveError> {
403 let mut ids = HashSet::new();
404 for stored in &self.drawings {
405 if stored.drawing.tool_type.is_cursor() {
406 return Err(SaveError::Cursor {
407 id: stored.id.clone(),
408 });
409 }
410 if !ids.insert(stored.id.as_str()) {
411 return Err(SaveError::DuplicateId {
412 id: stored.id.clone(),
413 });
414 }
415 if let Some(field) = stored.invalid_field() {
416 return Err(SaveError::NonFinite {
417 id: stored.id.clone(),
418 field,
419 });
420 }
421 if let Some(point) = stored
422 .points
423 .iter()
424 .position(|point| point.time().is_none())
425 {
426 return Err(SaveError::UnmappedAnchor {
427 id: stored.id.clone(),
428 point,
429 });
430 }
431 if let Some((needed, anchors)) = stored.missing_anchors() {
432 return Err(SaveError::MissingAnchors {
433 id: stored.id.clone(),
434 tool: stored.drawing.tool_type,
435 anchors,
436 needed,
437 });
438 }
439 }
440 serde_json::to_string_pretty(self).map_err(SaveError::Json)
441 }
442
443 pub fn from_json(json: &str) -> Result<(Self, Vec<SkippedDrawing>), LoadError> {
459 #[derive(Deserialize)]
460 struct Version {
461 version: u32,
462 }
463 #[derive(Deserialize)]
464 struct Container {
465 symbol: String,
466 timeframe: Option<String>,
467 drawings: Vec<serde_json::Value>,
468 metadata: StorageMetadata,
469 }
470 let version = serde_json::from_str::<Version>(json)
471 .map_err(LoadError::Json)?
472 .version;
473 let load: fn(serde_json::Value) -> Result<StoredDrawing, SkipReason> = match version {
474 1 => v1::load,
475 2..=Self::CURRENT_VERSION => |value| {
476 if let Some(name) = value.pointer("/drawing/tool_type").and_then(|t| t.as_str()) {
479 DrawingToolType::deserialize(name.into_deserializer()).map_err(
480 |_: serde::de::value::Error| {
481 SkipReason::UnknownTool(ParseToolTypeError { input: name.into() })
482 },
483 )?;
484 }
485 serde_json::from_value(value).map_err(SkipReason::Invalid)
486 },
487 version => {
488 return Err(LoadError::UnsupportedVersion {
489 found: version,
490 supported: 1..=Self::CURRENT_VERSION,
491 });
492 }
493 };
494 let container: Container = serde_json::from_str(json).map_err(LoadError::Json)?;
495 let mut skipped = Vec::new();
496 let mut ids = HashSet::new();
497 let drawings = container
498 .drawings
499 .into_iter()
500 .enumerate()
501 .filter_map(|(index, value)| {
502 let id = value.get("id").and_then(|id| id.as_str()).map(String::from);
503 load(value)
504 .and_then(|mut drawing| {
505 let tool = drawing.drawing.tool_type;
507 if version < 3 {
508 match tool {
511 DrawingToolType::Ray => {
512 let reversed = drawing.points.get(..2).is_some_and(|p| {
513 (p[0].ts_ms, p[0].subms_ns) > (p[1].ts_ms, p[1].subms_ns)
514 });
515 drawing.drawing.extend_left = reversed;
516 drawing.drawing.extend_right = !reversed;
517 }
518 DrawingToolType::ExtendedLine => {
519 drawing.drawing.extend_left = true;
520 drawing.drawing.extend_right = true;
521 }
522 DrawingToolType::Pitchfork
523 | DrawingToolType::SchiffPitchfork
524 | DrawingToolType::ModifiedSchiffPitchfork
525 | DrawingToolType::InsidePitchfork => {
526 drawing.drawing.extend_left = false;
527 drawing.drawing.extend_right = false;
528 }
529 _ => {}
530 }
531 }
532 if tool.is_cursor() {
533 return Err(SkipReason::Cursor { tool });
534 }
535 if drawing.points.iter().any(|point| point.time().is_none()) {
536 return Err(SkipReason::Invalid(serde::de::Error::custom(
537 "anchor timestamp is outside the supported range",
538 )));
539 }
540 if let Some(field) = drawing.invalid_field() {
541 return Err(SkipReason::Invalid(serde::de::Error::custom(format!(
542 "non-finite or out-of-range {field}"
543 ))));
544 }
545 if version < 4 {
546 let settings = &mut drawing.drawing;
550 let factor = match tool {
551 DrawingToolType::Brush => 2.0,
552 DrawingToolType::Highlighter => {
553 settings.color[3] = 100;
554 8.0
555 }
556 _ => 1.0,
557 };
558 settings.stroke_width = (settings.stroke_width * factor)
559 .min(super::validation::MAX_STROKE_WIDTH);
560 }
561 use DrawingToolType as T;
564 if version < 5
568 && matches!(tool, T::ElliottDoubleCombo | T::ElliottTripleCombo)
569 && let Some(first) = drawing.points.first().cloned()
570 {
571 drawing.points.insert(0, first);
572 }
573 if version < 6 {
574 let settings = &mut drawing.drawing;
577 if matches!(
578 tool,
579 DrawingToolType::ArrowMarkUp
580 | DrawingToolType::ArrowMarkDown
581 | DrawingToolType::ArrowMarkLeft
582 | DrawingToolType::ArrowMarkRight
583 ) {
584 if let Some(fill) = settings.fill_color.take() {
585 settings.color = fill;
586 } else if tool == DrawingToolType::ArrowMarkUp {
587 settings.color = [8, 153, 129, 255];
588 } else if tool == DrawingToolType::ArrowMarkDown {
589 settings.color = [242, 54, 69, 255];
590 }
591 }
592 }
593 match (tool, drawing.points.as_slice()) {
594 (T::PriceNote | T::ArrowMarker, [a]) => {
595 drawing.points.push(a.clone());
596 }
597 (T::BarsPattern, [a, b]) => {
598 let left = if (b.ts_ms, b.subms_ns) < (a.ts_ms, a.subms_ns) {
600 b
601 } else {
602 a
603 };
604 let copy = StoredCoord {
605 price: a.price,
606 ..left.clone()
607 };
608 drawing.points.push(copy);
609 }
610 (T::RotatedRect, [a, b]) => {
611 drawing.points = vec![
612 a.clone(),
613 StoredCoord {
614 price: a.price,
615 ..b.clone()
616 },
617 b.clone(),
618 ];
619 }
620 (T::DoubleCurve, [a, b]) => {
621 drawing.points = vec![a.clone(), a.clone(), b.clone(), b.clone()];
622 }
623 (T::FibonacciWedge | T::Pitchfan, [a, b]) => {
624 drawing.points.push(StoredCoord {
625 price: a.price,
626 ..b.clone()
627 });
628 }
629 _ => {}
630 }
631 if let Some((needed, anchors)) = drawing.missing_anchors() {
632 return Err(SkipReason::MissingAnchors {
633 tool,
634 anchors,
635 needed,
636 });
637 }
638 let visibility = &mut drawing.drawing.timeframe_visibility;
640 visibility.show_on_all |= visibility.visible_timeframes.is_empty()
641 && visibility.ranges.is_empty();
642 if ids.insert(drawing.id.clone()) {
643 Ok(drawing)
644 } else {
645 Err(SkipReason::DuplicateId {
646 id: drawing.id.clone(),
647 })
648 }
649 })
650 .map_err(|error| skipped.push(SkippedDrawing { index, id, error }))
651 .ok()
652 })
653 .collect();
654 let storage = Self {
655 version: Self::CURRENT_VERSION,
656 symbol: container.symbol,
657 timeframe: container.timeframe,
658 drawings,
659 metadata: container.metadata,
660 };
661 Ok((storage, skipped))
662 }
663
664 pub fn clear(&mut self) {
666 self.drawings.clear();
667 self.metadata.modified_at = chrono::Utc::now().timestamp_millis();
668 }
669}
670
671#[derive(Debug)]
673pub enum SaveError {
674 NonFinite {
677 id: String,
679 field: String,
681 },
682 DuplicateId {
684 id: String,
686 },
687 Cursor {
689 id: String,
691 },
692 MissingAnchors {
694 id: String,
696 tool: DrawingToolType,
698 anchors: usize,
700 needed: usize,
702 },
703 UnmappedAnchor {
705 id: String,
707 point: usize,
709 },
710 Json(serde_json::Error),
712}
713
714impl fmt::Display for SaveError {
715 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716 match self {
717 Self::NonFinite { id, field } => {
718 write!(f, "drawing {id:?} has a non-finite or out-of-range {field}")
719 }
720 Self::DuplicateId { id } => write!(f, "duplicate drawing id {id:?}"),
721 Self::Cursor { id } => write!(f, "drawing {id:?} is a cursor, not a drawing"),
722 Self::MissingAnchors {
723 id,
724 tool,
725 anchors,
726 needed,
727 } => write!(
728 f,
729 "drawing {id:?} has {anchors} anchors, {tool} needs {needed}"
730 ),
731 Self::UnmappedAnchor { id, point } => {
732 write!(f, "drawing {id:?} anchor {point} has no time mapping")
733 }
734 Self::Json(error) => error.fmt(f),
735 }
736 }
737}
738
739impl std::error::Error for SaveError {
740 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
741 match self {
742 Self::NonFinite { .. }
743 | Self::DuplicateId { .. }
744 | Self::Cursor { .. }
745 | Self::MissingAnchors { .. }
746 | Self::UnmappedAnchor { .. } => None,
747 Self::Json(error) => Some(error),
748 }
749 }
750}
751
752#[derive(Debug)]
755pub struct SkippedDrawing {
756 pub index: usize,
758 pub id: Option<String>,
760 pub error: SkipReason,
762}
763
764#[derive(Debug)]
770#[non_exhaustive]
771pub enum SkipReason {
772 UnknownTool(ParseToolTypeError),
774 Cursor {
776 tool: DrawingToolType,
778 },
779 MissingAnchors {
781 tool: DrawingToolType,
783 anchors: usize,
785 needed: usize,
787 },
788 DuplicateId {
790 id: String,
792 },
793 Invalid(serde_json::Error),
797}
798
799impl fmt::Display for SkipReason {
800 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801 match self {
802 Self::UnknownTool(error) => error.fmt(f),
803 Self::Cursor { tool } => write!(f, "{tool} is a cursor, not a drawing"),
804 Self::MissingAnchors {
805 tool,
806 anchors,
807 needed,
808 } => write!(f, "{tool} needs {needed} anchors, has {anchors}"),
809 Self::DuplicateId { id } => write!(f, "duplicate drawing id {id:?}"),
810 Self::Invalid(error) => error.fmt(f),
811 }
812 }
813}
814
815impl std::error::Error for SkipReason {
816 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
817 match self {
818 Self::UnknownTool(error) => Some(error),
819 Self::Invalid(error) => Some(error),
820 Self::Cursor { .. } | Self::MissingAnchors { .. } | Self::DuplicateId { .. } => None,
821 }
822 }
823}
824
825#[derive(Debug)]
832#[non_exhaustive]
833pub enum LoadError {
834 Json(serde_json::Error),
837 UnsupportedVersion {
839 found: u32,
841 supported: std::ops::RangeInclusive<u32>,
843 },
844 OtherSymbol,
846 NeedsHistory,
848 NotClipboard,
851 Skipped(Vec<SkippedDrawing>),
854}
855
856impl fmt::Display for LoadError {
857 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
858 match self {
859 Self::Json(error) => error.fmt(f),
860 Self::UnsupportedVersion { found, supported } => write!(
861 f,
862 "unsupported drawing storage version {found}, expected {} to {}",
863 supported.start(),
864 supported.end()
865 ),
866 Self::OtherSymbol => f.write_str("drawing storage belongs to a different symbol"),
867 Self::NeedsHistory => f.write_str("clipboard needs loaded chart history"),
868 Self::NotClipboard => f.write_str("not a drawing clipboard"),
869 Self::Skipped(skipped) if skipped.len() == 1 => {
870 f.write_str("1 drawing could not be read")
871 }
872 Self::Skipped(skipped) => write!(f, "{} drawings could not be read", skipped.len()),
873 }
874 }
875}
876
877impl std::error::Error for LoadError {
878 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
879 match self {
880 Self::Json(error) => Some(error),
881 _ => None,
882 }
883 }
884}
885
886#[derive(Debug)]
889#[non_exhaustive]
890pub enum StorageError {
891 Read {
894 symbol: String,
896 error: LoadError,
898 },
899 Save {
901 symbol: String,
903 error: SaveError,
905 },
906 Storage(String),
909}
910
911impl fmt::Display for StorageError {
912 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
913 match self {
914 Self::Read { symbol, error } => {
915 write!(f, "Could not read drawings of {symbol}: {error}")
916 }
917 Self::Save { symbol, error } => {
918 write!(f, "Could not save drawings of {symbol}: {error}")
919 }
920 Self::Storage(message) => f.write_str(message),
921 }
922 }
923}
924
925impl std::error::Error for StorageError {
926 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
927 match self {
928 Self::Read { error, .. } => Some(error),
929 Self::Save { error, .. } => Some(error),
930 Self::Storage(_) => None,
931 }
932 }
933}
934
935mod v1 {
938 use super::StoredCoord;
939 use crate::drawings::{Drawing, DrawingToolType};
940 use serde::Deserialize;
941 use std::collections::HashMap;
942
943 #[derive(Deserialize)]
944 struct StoredDrawing {
945 id: String,
946 tool_type: String,
947 points: Vec<StoredCoord>,
948 color: Color,
949 line_width: f32,
950 locked: bool,
951 visible: bool,
952 properties: HashMap<String, serde_json::Value>,
953 created_at: i64,
954 modified_at: i64,
955 notes: Option<String>,
956 }
957
958 #[derive(Deserialize)]
959 struct Color {
960 r: u8,
961 g: u8,
962 b: u8,
963 a: u8,
964 }
965
966 pub(super) fn load(
968 value: serde_json::Value,
969 ) -> Result<super::StoredDrawing, super::SkipReason> {
970 let stored: StoredDrawing =
971 serde_json::from_value(value).map_err(super::SkipReason::Invalid)?;
972 let tool_type: DrawingToolType = stored
975 .tool_type
976 .parse()
977 .map_err(super::SkipReason::UnknownTool)?;
978 let Color { r, g, b, a } = stored.color;
979 let mut drawing = Drawing::with_color(0, tool_type, [r, g, b, a]);
980 drawing.fib_config = None;
981 drawing.stroke_width = stored.line_width;
982 drawing.locked = stored.locked;
983 drawing.visible = stored.visible;
984 drawing.text = stored
985 .properties
986 .get("text")
987 .and_then(|text| text.as_str())
988 .map(String::from);
989 Ok(super::StoredDrawing {
990 id: stored.id,
991 points: stored.points,
992 drawing,
993 created_at: stored.created_at,
994 modified_at: stored.modified_at,
995 notes: stored.notes,
996 pane: None,
997 })
998 }
999}
1000
1001pub fn drawing_to_stored(drawing: &Drawing, bar_to_time: impl Fn(f32) -> i64) -> StoredDrawing {
1012 drawing_to_stored_with(drawing, |index, price| {
1013 StoredCoord::new(bar_to_time(index), price)
1014 })
1015}
1016
1017pub(crate) fn drawing_to_stored_with(
1018 drawing: &Drawing,
1019 bar_to_coord: impl Fn(f32, f64) -> StoredCoord,
1020) -> StoredDrawing {
1021 let mut settings = drawing.clone();
1022 settings.points.clear();
1023 settings.chart_points.clear();
1024 let id = drawing.stored_id.clone();
1025 let mut stored = StoredDrawing::new(id.unwrap_or_else(|| drawing.id.to_string()), settings);
1026 stored.created_at = drawing.created_at.unwrap_or(stored.created_at);
1027 stored.notes.clone_from(&drawing.notes);
1028 stored.points = drawing
1029 .chart_points
1030 .iter()
1031 .map(|p| {
1032 p.time_anchor
1033 .filter(|(_, index)| *index == p.bar_idx)
1034 .map_or_else(
1035 || bar_to_coord(p.bar_idx, p.price),
1036 |(time, _)| StoredCoord::from_time(time, p.price),
1037 )
1038 })
1039 .collect();
1040 stored
1041}
1042
1043pub fn stored_to_drawing(
1052 stored: &StoredDrawing,
1053 time_to_bar: impl Fn(i64) -> f32,
1054 next_id: usize,
1055) -> Drawing {
1056 stored_to_drawing_with(stored, |point| time_to_bar(point.ts_ms), next_id)
1057}
1058
1059pub(crate) fn stored_to_drawing_with(
1060 stored: &StoredDrawing,
1061 time_to_bar: impl Fn(&StoredCoord) -> f32,
1062 next_id: usize,
1063) -> Drawing {
1064 let mut drawing = stored.drawing.clone();
1065 drawing.id = next_id;
1066 drawing.stored_id = Some(stored.id.clone());
1067 drawing.created_at = Some(stored.created_at);
1068 drawing.notes.clone_from(&stored.notes);
1069 drawing.chart_points = stored
1070 .points
1071 .iter()
1072 .map(|p| {
1073 let mut point = ChartPoint::new(time_to_bar(p), p.price);
1074 point.time_anchor = p.time().map(|time| (time, point.bar_idx));
1075 point
1076 })
1077 .collect();
1078 drawing.completed = true;
1079 drawing
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use crate::data::VolumeProfileConfig;
1086 use crate::drawings::*;
1087 use crate::model::LineStyle;
1088
1089 const T0: i64 = 1_704_067_200_000;
1090
1091 fn bar_to_time(bar: f32) -> i64 {
1092 T0 + (bar * 60_000.0) as i64
1093 }
1094
1095 fn time_to_bar(ts_ms: i64) -> f32 {
1096 (ts_ms - T0) as f32 / 60_000.0
1097 }
1098
1099 fn bar_to_x(bar: f32) -> f32 {
1100 100.0 + bar * 8.0
1101 }
1102
1103 fn price_to_y(price: f64) -> f32 {
1104 (500.0 - price * 2.0) as f32
1105 }
1106
1107 fn restore(stored: &StoredDrawing, id: usize) -> Drawing {
1110 let mut drawing = stored_to_drawing(stored, time_to_bar, id);
1111 drawing.update_screen_coords(bar_to_x, price_to_y);
1112 (drawing.stored_id, drawing.created_at, drawing.notes) = (None, None, None);
1113 drawing
1114 }
1115
1116 fn non_default_drawing(tool: DrawingToolType) -> Drawing {
1119 let mut d = Drawing::new(7, tool);
1120 d.chart_points = vec![
1121 ChartPoint::new(3.0, 101.25),
1122 ChartPoint::new(10.5, 99.5),
1123 ChartPoint::new(-2.0, 120.0),
1124 ];
1125 d.color = [1, 2, 3, 4];
1126 d.stroke_width = 3.5;
1127 d.completed = true;
1128 d.text = Some("note".into());
1129 d.label = Some("Support".into());
1130 d.line_style = LineStyle::Dotted;
1131 d.fill_color = Some([5, 6, 7, 8]);
1132 d.locked = true;
1133 d.visible = false;
1134 d.font_size = 18.0;
1135 d.font_weight = FontWeight::Bold;
1136 d.extend_left = !d.extend_left;
1137 d.extend_right = !d.extend_right;
1138 d.arrow_start_style = ArrowStyle::Diamond;
1139 d.arrow_end_style = ArrowStyle::Circle;
1140 d.timeframe_visibility = TimeframeVisibility::specific(&["1h"]);
1141 d.price_axis_label = false;
1142 d.time_axis_label = false;
1143 d.position_sizing = PositionSizing {
1144 point_value: 50.0,
1145 lot_size: 10.0,
1146 quantity_step: 0.25,
1147 leverage: 5.0,
1148 };
1149 d.position_size = Some(PositionSize::RiskPercent {
1150 account_size: 25_000.0,
1151 percent: 1.5,
1152 });
1153 d.fib_config = Some(FibonacciConfig {
1154 levels: vec![FibonacciLevel {
1155 value: 1.618,
1156 label: "golden".into(),
1157 color: [9, 10, 11, 12],
1158 visible: false,
1159 show_price: false,
1160 }],
1161 level_width: 3.5,
1162 level_style: LineStyle::Dashed,
1163 show_background: true,
1164 background_opacity: 77,
1165 extend_left: true,
1166 extend_right: true,
1167 reverse: true,
1168 logarithmic: true,
1169 ..FibonacciConfig::default()
1170 });
1171 d.volume_profile = VolumeProfileConfig::new(50, 0.68);
1172 d.regression_trend = RegressionTrendConfig::new(1.5);
1173 let mut vwap = VwapConfig::default();
1174 vwap.bands[0].enabled = false;
1175 vwap.bands[1] = VwapBand::new(true, 2.5).unwrap();
1176 d.vwap = Some(vwap);
1177 if tool == DrawingToolType::DoubleCurve {
1178 d.chart_points.push(ChartPoint::new(12.0, 115.0));
1179 }
1180 if tool == DrawingToolType::ElliottTriangle {
1181 d.chart_points.extend([
1182 ChartPoint::new(12.0, 115.0),
1183 ChartPoint::new(14.0, 104.0),
1184 ChartPoint::new(16.0, 112.0),
1185 ]);
1186 }
1187 d.italic = true;
1188 d.text_color = Some([13, 14, 15, 16]);
1189 d.background = false;
1190 d.border = false;
1191 d.border_color = Some([17, 18, 19, 20]);
1192 d.z_order = -3;
1193 d.update_screen_coords(bar_to_x, price_to_y);
1194 d
1195 }
1196
1197 #[test]
1198 fn every_tool_round_trips_with_all_settings() {
1199 use crate::data::ProfileDisplay::{Delta, Total, UpDown};
1200 for (tool, custom_anchors) in DrawingToolType::all()
1201 .iter()
1202 .copied()
1203 .filter(|t| !t.is_cursor())
1204 .map(|tool| (tool, None))
1205 .chain((1..=3).map(|anchors| (DrawingToolType::Polyline, Some(anchors))))
1206 {
1207 let mut drawing = non_default_drawing(tool);
1208 if let Some(anchors) = custom_anchors {
1209 drawing.custom_tool = Some("host-shape".into());
1210 drawing.custom_settings = serde_json::json!({"radius": 10});
1211 drawing.chart_points.truncate(anchors);
1212 drawing.update_screen_coords(bar_to_x, price_to_y);
1213 }
1214 drawing.gann = Some(crate::drawings::GannConfig {
1215 price_levels: vec![
1216 GannLevel::new(0.25, [1, 2, 3, 255]),
1217 GannLevel::new(0.75, [4, 5, 6, 255]),
1218 ],
1219 time_levels: vec![GannLevel::new(0.5, [7, 8, 9, 255])],
1220 show_grid: false,
1221 show_fan: false,
1222 fan_ratios: vec![
1223 GannLevel::new(0.5, [10, 11, 12, 255]),
1224 GannLevel {
1225 visible: false,
1226 ..GannLevel::new(2.0, [13, 14, 15, 255])
1227 },
1228 ],
1229 fan_direction: crate::drawings::GannFanDirection::Reverse,
1230 show_arcs: false,
1231 arc_ratios: vec![GannLevel::new(0.75, [16, 17, 18, 255])],
1232 show_background: false,
1233 background_opacity: 7,
1234 show_angles: true,
1235 });
1236 drawing.ghost_feed = Some(crate::drawings::GhostFeedConfig {
1237 average_range_ticks: 2.5,
1238 noise_ticks: 0.5,
1239 });
1240 if tool == DrawingToolType::Image {
1241 drawing.image = Some(crate::drawings::ImageConfig {
1242 size: Some([240.0, 135.0]),
1243 opacity: 128,
1244 });
1245 }
1246 drawing.bars_pattern = Some(crate::drawings::BarsPatternConfig {
1247 mode: crate::drawings::BarsPatternMode::Line,
1248 mirrored: true,
1249 flipped: true,
1250 });
1251 drawing.update_screen_coords(bar_to_x, price_to_y);
1252 let mut fork = crate::drawings::PitchforkConfig::default();
1253 fork.levels[0].line_style = LineStyle::Dotted;
1254 fork.levels[1].visible = false;
1255 fork.show_background = false;
1256 drawing.pitchfork = Some(fork);
1257 drawing.channel_midline = true;
1258 drawing.closed = true;
1259 drawing.elliott_degree = ElliottDegree::Minuette;
1260 drawing.selectable = false;
1261 drawing.listed = false;
1262 drawing.group = Some("levels".into());
1263 drawing.folder = vec!["Analysis".into(), "Support / resistance".into()];
1264 if matches!(
1265 tool,
1266 DrawingToolType::AnchoredText | DrawingToolType::AnchoredNote
1267 ) {
1268 drawing.screen_anchor = Some([0.25, 0.75]);
1269 }
1270
1271 let modes = if matches!(
1272 tool,
1273 DrawingToolType::FixedRangeVolumeProfile | DrawingToolType::AnchoredVolumeProfile
1274 ) {
1275 &[Total, UpDown, Delta][..]
1276 } else {
1277 &[Total][..]
1278 };
1279 for &display in modes {
1280 let config = drawing.volume_profile.as_mut().unwrap();
1281 *config = config.with_row_size(Some(0.25)).unwrap();
1282 config.display = display;
1283 config.up_color = [1, 2, 3, 240];
1284 config.down_color = [4, 5, 6, 200];
1285 let mut storage = DrawingStorage::new("AAPL");
1286 storage.add_drawing(drawing_to_stored(&drawing, bar_to_time));
1287 let (loaded, skipped) =
1288 DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
1289 assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1290 let back = restore(&loaded.drawings()[0], drawing.id);
1291 assert_eq!(
1292 format!("{back:?}"),
1293 format!("{drawing:?}"),
1294 "{tool:?} {display:?}"
1295 );
1296 }
1297 }
1298 }
1299
1300 #[test]
1305 fn a_later_session_keeps_the_stored_drawings_and_their_ids() {
1306 let mut storage = DrawingStorage::new("AAPL");
1307 for (id, notes, z_order) in [("a1b2", Some("breakout level"), 1), ("2", None, 0)] {
1308 let mut drawing = Drawing::new(0, DrawingToolType::HorizontalLine);
1309 drawing.z_order = z_order;
1310 let mut stored = StoredDrawing::new(id, drawing);
1311 stored.points = vec![StoredCoord::new(T0, 100.0)];
1312 stored.created_at = 1_700_000_000_000;
1313 stored.notes = notes.map(String::from);
1314 storage.add_drawing(stored);
1315 }
1316 let (mut storage, _) = DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
1317
1318 let mut manager = DrawingManager::new();
1319 for stored in storage.drawings() {
1320 manager
1321 .add_synced_drawing(stored_to_drawing(stored, time_to_bar, 0))
1322 .unwrap();
1323 }
1324 manager
1325 .add_synced_drawing(manager.drawings[0].clone())
1326 .unwrap();
1327 let (x_to_bar, y_to_price) = (|x: f32| x, |y: f32| f64::from(y));
1328 let at = egui::Pos2::new(5.0, 90.0);
1329 manager.start_drawing_with_coords(
1330 DrawingToolType::HorizontalLine,
1331 at,
1332 x_to_bar,
1333 y_to_price,
1334 );
1335 for drawing in &manager.drawings {
1336 storage.add_drawing(drawing_to_stored(drawing, bar_to_time));
1337 }
1338 let (saved, _) = DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
1339
1340 let records: Vec<_> = saved
1341 .drawings()
1342 .iter()
1343 .map(|d| {
1344 (
1345 d.id.as_str(),
1346 d.created_at,
1347 d.notes.as_deref(),
1348 d.drawing.z_order,
1349 )
1350 })
1351 .collect();
1352 assert_eq!(records.len(), 4, "{records:?}");
1353 assert_eq!(
1354 records[0],
1355 ("a1b2", 1_700_000_000_000, Some("breakout level"), 1)
1356 );
1357 assert_eq!(records[1], ("2", 1_700_000_000_000, None, 0));
1358 let ids: HashSet<_> = records.iter().map(|r| r.0).collect();
1359 assert_eq!(ids.len(), 4, "{records:?}");
1360 }
1361
1362 #[test]
1366 fn from_json_reads_saved_files_and_skips_the_drawings_it_cannot_read() {
1367 let v1_json = include_str!("fixtures/storage_v1.json");
1368 let (v1, skipped) = DrawingStorage::from_json(v1_json).unwrap();
1369 assert!(skipped.is_empty(), "{skipped:?}");
1370 assert_eq!(v1.version, DrawingStorage::CURRENT_VERSION);
1371 assert_eq!(v1.timeframe.as_deref(), Some("1h"));
1372 let tools = [
1373 (DrawingToolType::TrendLine, None),
1374 (DrawingToolType::FibonacciRetracement, None),
1375 (DrawingToolType::ElliottImpulse, None),
1376 (DrawingToolType::Arrow, None),
1377 (DrawingToolType::TextLabel, Some("breakout")),
1378 ];
1379 assert_eq!(v1.drawings().len(), tools.len());
1380 for (stored, (tool, text)) in v1.drawings().iter().zip(tools) {
1381 let mut want = Drawing::with_color(1, tool, [33, 150, 243, 255]);
1382 want.fib_config = None;
1384 want.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 110.0)];
1385 want.update_screen_coords(bar_to_x, price_to_y);
1386 want.stroke_width = 3.0;
1387 want.completed = true;
1388 want.text = text.map(String::from);
1389 want.locked = tool == DrawingToolType::Arrow;
1391 want.visible = tool != DrawingToolType::Arrow;
1392 assert_eq!(format!("{:?}", restore(stored, 1)), format!("{want:?}"));
1393 }
1394
1395 let (v2, skipped) =
1396 DrawingStorage::from_json(include_str!("fixtures/storage_v2.json")).unwrap();
1397 assert!(skipped.is_empty(), "{skipped:?}");
1398 let tools = [
1399 DrawingToolType::TrendLine,
1400 DrawingToolType::LongPos,
1401 DrawingToolType::FixedRangeVolumeProfile,
1402 ];
1403 assert_eq!(v2.drawings().len(), tools.len());
1404 for (stored, tool) in v2.drawings().iter().zip(tools) {
1405 let mut want = non_default_drawing(tool);
1406 want.label = None; want.position_sizing = PositionSizing::default(); want.vwap = None; want.price_axis_label = true;
1411 want.time_axis_label = true;
1412 want.italic = false;
1414 want.text_color = None;
1415 want.background = true;
1416 want.border = true;
1417 want.border_color = None;
1418 let config = want.fib_config.as_mut().unwrap();
1419 config.logarithmic = false;
1420 config.level_width = 2.0;
1421 config.level_style = LineStyle::Solid;
1422 config.levels_as_percent = true;
1423 config.labels_on_left = false;
1424 assert_eq!(
1425 format!("{:?}", restore(stored, want.id)),
1426 format!("{want:?}")
1427 );
1428 }
1429
1430 for tool in [
1432 DrawingToolType::FibonacciTimeZones,
1433 DrawingToolType::TrendBasedFibTime,
1434 DrawingToolType::FibonacciSpeedFan,
1435 DrawingToolType::FibonacciCircles,
1436 DrawingToolType::FibonacciSpeedResistanceArcs,
1437 ] {
1438 for (fixture, legacy) in [
1439 (v1_json, true),
1440 (include_str!("fixtures/storage_v2.json"), false),
1441 ] {
1442 let mut value: serde_json::Value = serde_json::from_str(fixture).unwrap();
1443 let records = value["drawings"].as_array_mut().unwrap();
1444 records.truncate(1);
1445 let fields = if legacy {
1446 &mut records[0]
1447 } else {
1448 &mut records[0]["drawing"]
1449 };
1450 fields["tool_type"] = serde_json::to_value(tool).unwrap();
1451 if !legacy {
1452 fields["fib_config"] = serde_json::Value::Null;
1453 }
1454 if tool == DrawingToolType::TrendBasedFibTime {
1455 let points = value["drawings"][0]["points"].as_array_mut().unwrap();
1456 points.push(points[0].clone());
1457 }
1458 let (loaded, skipped) = DrawingStorage::from_json(&value.to_string()).unwrap();
1459 assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1460 assert!(
1461 loaded.drawings[0].drawing.fib_config.is_none(),
1462 "{tool:?}, legacy={legacy}"
1463 );
1464 let (again, skipped) =
1465 DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1466 assert!(skipped.is_empty());
1467 assert!(
1468 again.drawings[0].drawing.fib_config.is_none(),
1469 "{tool:?}: retain fixed levels after saving"
1470 );
1471 }
1472 }
1473
1474 for (tool, name) in [
1477 (DrawingToolType::Ray, "Ray"),
1478 (DrawingToolType::ExtendedLine, "Extended Line"),
1479 ] {
1480 for (fixture, tool_path, tool_name) in [
1481 (
1482 include_str!("fixtures/storage_v1.json"),
1483 "/tool_type",
1484 name.to_owned(),
1485 ),
1486 (
1487 include_str!("fixtures/storage_v2.json"),
1488 "/drawing/tool_type",
1489 serde_json::to_value(tool)
1490 .unwrap()
1491 .as_str()
1492 .unwrap()
1493 .to_owned(),
1494 ),
1495 ] {
1496 for reversed in [false, true] {
1497 let mut saved: serde_json::Value = serde_json::from_str(fixture).unwrap();
1498 let records = saved["drawings"].as_array_mut().unwrap();
1499 records.truncate(1);
1500 *records[0].pointer_mut(tool_path).unwrap() = tool_name.clone().into();
1501 records[0]["points"].as_array_mut().unwrap().truncate(2);
1502 if reversed {
1503 records[0]["points"].as_array_mut().unwrap().reverse();
1504 }
1505 if let Some(settings) = records[0].get_mut("drawing") {
1506 settings["extend_left"] = true.into();
1507 settings["extend_right"] = false.into();
1508 }
1509 let (mut loaded, skipped) =
1510 DrawingStorage::from_json(&saved.to_string()).unwrap();
1511 assert!(skipped.is_empty(), "{skipped:?}");
1512 let drawing = &mut loaded.drawings[0].drawing;
1513 assert_eq!(
1514 drawing.extend_left,
1515 reversed || tool == DrawingToolType::ExtendedLine
1516 );
1517 assert_eq!(
1518 drawing.extend_right,
1519 !reversed || tool == DrawingToolType::ExtendedLine
1520 );
1521 drawing.extend_left = false;
1522 drawing.extend_right = false;
1523 let (again, skipped) =
1524 DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1525 assert!(skipped.is_empty());
1526 assert!(!again.drawings[0].drawing.extend_left);
1527 assert!(!again.drawings[0].drawing.extend_right);
1528 }
1529 }
1530 }
1531
1532 for (version, tool, fill, want) in [
1535 (1, DrawingToolType::ArrowMarkUp, None, [8, 153, 129, 255]),
1536 (3, DrawingToolType::ArrowMarkUp, None, [8, 153, 129, 255]),
1537 (4, DrawingToolType::ArrowMarkDown, None, [242, 54, 69, 255]),
1538 (5, DrawingToolType::ArrowMarkLeft, None, [10, 20, 30, 255]),
1539 (
1540 5,
1541 DrawingToolType::ArrowMarkUp,
1542 Some([1, 2, 3, 90]),
1543 [1, 2, 3, 90],
1544 ),
1545 (
1546 2,
1547 DrawingToolType::ArrowMarkRight,
1548 Some([1, 2, 3, 90]),
1549 [1, 2, 3, 90],
1550 ),
1551 (6, DrawingToolType::ArrowMarkUp, None, [10, 20, 30, 255]),
1552 ] {
1553 let fixture = if version == 1 {
1554 include_str!("fixtures/storage_v1.json")
1555 } else {
1556 include_str!("fixtures/storage_v2.json")
1557 };
1558 let mut saved: serde_json::Value = serde_json::from_str(fixture).unwrap();
1559 saved["version"] = version.into();
1560 let records = saved["drawings"].as_array_mut().unwrap();
1561 records.truncate(1);
1562 records[0]["points"].as_array_mut().unwrap().truncate(1);
1563 if version == 1 {
1564 records[0]["tool_type"] = tool.to_string().into();
1565 records[0]["color"] = serde_json::json!({"r": 10, "g": 20, "b": 30, "a": 255});
1566 } else {
1567 let settings = &mut records[0]["drawing"];
1568 settings["tool_type"] = serde_json::to_value(tool).unwrap();
1569 settings["color"] = serde_json::json!([10, 20, 30, 255]);
1570 settings["fill_color"] = serde_json::to_value(fill).unwrap();
1571 }
1572 let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1573 assert!(skipped.is_empty(), "{tool:?} v{version}: {skipped:?}");
1574 let drawing = &loaded.drawings[0].drawing;
1575 assert_eq!(
1576 (drawing.color, drawing.fill_color),
1577 (want, None),
1578 "{tool:?} v{version}"
1579 );
1580 let (again, skipped) = DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1581 assert!(skipped.is_empty());
1582 let drawing = &again.drawings[0].drawing;
1583 assert_eq!(
1584 (drawing.color, drawing.fill_color),
1585 (want, None),
1586 "{tool:?} v{version}: converted twice"
1587 );
1588 }
1589
1590 for tool in [
1592 DrawingToolType::Pitchfork,
1593 DrawingToolType::SchiffPitchfork,
1594 DrawingToolType::ModifiedSchiffPitchfork,
1595 DrawingToolType::InsidePitchfork,
1596 ] {
1597 let mut saved: serde_json::Value =
1598 serde_json::from_str(include_str!("fixtures/storage_v2.json")).unwrap();
1599 saved["drawings"].as_array_mut().unwrap().truncate(1);
1600 saved["drawings"][0]["drawing"]["tool_type"] = serde_json::to_value(tool).unwrap();
1601 let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1602 assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1603 let drawing = &loaded.drawings[0].drawing;
1604 assert!(!drawing.extend_left && !drawing.extend_right, "{tool:?}");
1605 }
1606
1607 for (version, tool, width, alpha, want_width, want_alpha) in [
1610 (2, DrawingToolType::Brush, 3.0, 200, 6.0, 200),
1611 (3, DrawingToolType::Brush, 3.0, 200, 6.0, 200),
1612 (3, DrawingToolType::Highlighter, 2.0, 255, 16.0, 100),
1613 (3, DrawingToolType::Highlighter, 20.0, 40, 100.0, 100),
1614 (4, DrawingToolType::Highlighter, 2.0, 40, 2.0, 40),
1615 ] {
1616 let mut saved: serde_json::Value =
1617 serde_json::from_str(include_str!("fixtures/storage_v2.json")).unwrap();
1618 saved["version"] = version.into();
1619 let records = saved["drawings"].as_array_mut().unwrap();
1620 records.truncate(1);
1621 let settings = &mut records[0]["drawing"];
1622 settings["tool_type"] = serde_json::to_value(tool).unwrap();
1623 settings["stroke_width"] = width.into();
1624 settings["color"] = serde_json::json!([10, 20, 30, alpha]);
1625 let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1626 assert!(skipped.is_empty(), "{tool:?} v{version}: {skipped:?}");
1627 let drawing = &loaded.drawings[0].drawing;
1628 assert_eq!(
1629 (drawing.stroke_width, drawing.color),
1630 (want_width, [10, 20, 30, want_alpha]),
1631 "{tool:?} v{version}"
1632 );
1633 let (again, _) = DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1634 assert_eq!(
1635 again.drawings[0].drawing.stroke_width, want_width,
1636 "{tool:?} v{version}: converted twice"
1637 );
1638 }
1639
1640 for (tool, count, origins) in [
1642 (DrawingToolType::Note, 1, &[0][..]),
1643 (DrawingToolType::Signpost, 1, &[0][..]),
1644 (DrawingToolType::PriceNote, 1, &[0, 0][..]),
1645 (DrawingToolType::ArrowMarker, 1, &[0, 0][..]),
1646 (DrawingToolType::RotatedRect, 2, &[0, 1, 1][..]),
1647 (DrawingToolType::DoubleCurve, 2, &[0, 0, 1, 1][..]),
1648 (DrawingToolType::FibonacciWedge, 2, &[0, 1, 1][..]),
1649 (DrawingToolType::Pitchfan, 2, &[0, 1, 1][..]),
1650 (DrawingToolType::BarsPattern, 2, &[0, 1, 0][..]),
1651 (DrawingToolType::ElliottDoubleCombo, 3, &[0, 0, 1, 2][..]),
1652 (
1653 DrawingToolType::ElliottTripleCombo,
1654 5,
1655 &[0, 0, 1, 2, 3, 4][..],
1656 ),
1657 (DrawingToolType::ElliottDoubleCombo, 2, &[0, 0, 1][..]),
1658 ] {
1659 let mut saved: serde_json::Value =
1660 serde_json::from_str(include_str!("fixtures/storage_v2.json")).unwrap();
1661 saved["version"] = 4.into();
1662 let records = saved["drawings"].as_array_mut().unwrap();
1663 records.truncate(1);
1664 records[0]["drawing"]["tool_type"] = serde_json::to_value(tool).unwrap();
1665 let points = records[0]["points"].as_array_mut().unwrap();
1666 let first = points[0].clone();
1667 points.resize(count, first);
1668 for (at, point) in records[0]["points"]
1669 .as_array_mut()
1670 .unwrap()
1671 .iter_mut()
1672 .enumerate()
1673 {
1674 point["subms_ns"] = (at + 1).into();
1675 }
1676 let original = records[0]["points"].as_array().unwrap().clone();
1677 let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1678 assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1679 let points = &loaded.drawings[0].points;
1680 assert_eq!(points.len(), origins.len(), "{tool:?}");
1681 for (point, &origin) in points.iter().zip(origins) {
1682 assert_eq!(
1683 point.ts_ms,
1684 original[origin]["ts_ms"].as_i64().unwrap(),
1685 "{tool:?}"
1686 );
1687 assert_eq!(
1688 u64::from(point.subms_ns),
1689 original[origin]["subms_ns"].as_u64().unwrap(),
1690 "{tool:?}"
1691 );
1692 }
1693 let points = serde_json::to_value(points).unwrap();
1694 for anchor in original {
1695 assert!(points.as_array().unwrap().contains(&anchor), "{tool:?}");
1696 }
1697 let (again, skipped) = DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1698 assert!(skipped.is_empty());
1699 assert_eq!(again.drawings[0].points.len(), origins.len());
1700 }
1701
1702 let mut first = Drawing::new(1, DrawingToolType::FixedRangeVolumeProfile);
1703 first.volume_profile = VolumeProfileConfig::new(24, 0.7);
1704 first.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 110.0)];
1705 let mut storage = DrawingStorage::new("AAPL");
1706 storage.add_drawing(drawing_to_stored(&first, bar_to_time));
1707 let mut second = Drawing::new(2, DrawingToolType::TrendLine);
1708 second.chart_points = first.chart_points.clone();
1709 storage.add_drawing(drawing_to_stored(&second, bar_to_time));
1710 let current = storage.to_json().unwrap();
1711 let short_of_anchors = |tool, anchors| {
1714 let mut storage = storage.clone();
1715 storage.drawings[0].drawing.tool_type = tool;
1716 storage.drawings[0].points.truncate(anchors);
1717 serde_json::to_string_pretty(&storage).unwrap()
1718 };
1719 let with_level_width = |width| {
1720 let mut value = serde_json::to_value(&storage).unwrap();
1721 value["drawings"][0]["drawing"]["pitchfork"] = serde_json::json!({
1722 "levels":[{"value":1.0,"visible":true,"color":[1,2,3,255],"line_style":"Solid","width":width}],
1723 "show_background":false,"background_opacity":51
1724 });
1725 serde_json::to_string(&value).unwrap()
1726 };
1727 let with_vwap_multiplier = |multiplier| {
1728 let mut value = serde_json::to_value(&storage).unwrap();
1729 let band = |m| serde_json::json!({ "enabled": true, "multiplier": m });
1730 value["drawings"][0]["drawing"]["vwap"] = serde_json::json!({
1731 "bands": [band(multiplier), band(2.0), band(3.0)]
1732 });
1733 serde_json::to_string(&value).unwrap()
1734 };
1735 let unreadable = [
1736 (
1737 with_vwap_multiplier(0.0),
1738 "anchored VWAP band multiplier out of bounds",
1739 &["2"][..],
1740 0,
1741 "invalid",
1742 ),
1743 (
1744 with_vwap_multiplier(10.5),
1745 "anchored VWAP band multiplier out of bounds",
1746 &["2"][..],
1747 0,
1748 "invalid",
1749 ),
1750 (
1751 with_level_width(0.0),
1752 "out-of-range pitchfork.levels[0].width",
1753 &["2"][..],
1754 0,
1755 "invalid",
1756 ),
1757 (
1758 with_level_width(101.0),
1759 "out-of-range pitchfork.levels[0].width",
1760 &["2"][..],
1761 0,
1762 "invalid",
1763 ),
1764 (
1765 current.replacen("\"ts_ms\":", "\"subms_ns\": 1000000, \"ts_ms\":", 1),
1766 "anchor timestamp is outside the supported range",
1767 &["2"][..],
1768 0,
1769 "invalid",
1770 ),
1771 (
1772 current.replacen("\"stroke_width\": 2.0", "\"stroke_width\": null", 1),
1773 "invalid type: null",
1774 &["2"][..],
1775 0,
1776 "invalid",
1777 ),
1778 (
1779 current.replacen("\"rows\": 24", "\"rows\": 0", 1),
1780 "volume profile settings out of bounds",
1781 &["2"],
1782 0,
1783 "invalid",
1784 ),
1785 (
1786 current.replace("FixedRangeVolumeProfile", "NewerTool"),
1787 "unknown drawing tool \"NewerTool\"",
1788 &["2"],
1789 0,
1790 "unknown tool NewerTool",
1791 ),
1792 (
1793 current.replace("FixedRangeVolumeProfile", "Eraser"),
1794 "Eraser is a cursor, not a drawing",
1795 &["2"],
1796 0,
1797 "cursor Eraser",
1798 ),
1799 (
1800 current.replacen("\"stroke_width\": 2.0", "\"stroke_width\": 1e39", 1),
1801 "out-of-range stroke_width",
1802 &["2"],
1803 0,
1804 "invalid",
1805 ),
1806 (
1807 current.replacen("\"stroke_width\": 2.0", "\"stroke_width\": -1.0", 1),
1808 "out-of-range stroke_width",
1809 &["2"],
1810 0,
1811 "invalid",
1812 ),
1813 (
1814 current.replacen("\"font_size\": 14.0", "\"font_size\": 100000.0", 1),
1815 "out-of-range font_size",
1816 &["2"],
1817 0,
1818 "invalid",
1819 ),
1820 (
1821 short_of_anchors(DrawingToolType::TrendLine, 0),
1822 "Trend Line needs 2 anchors, has 0",
1823 &["2"],
1824 0,
1825 "TrendLine has 0 of 2 anchors",
1826 ),
1827 (
1828 short_of_anchors(DrawingToolType::Rect, 1),
1829 "Rectangle needs 2 anchors, has 1",
1830 &["2"],
1831 0,
1832 "Rect has 1 of 2 anchors",
1833 ),
1834 (
1835 short_of_anchors(DrawingToolType::Pitchfork, 2),
1836 "Pitchfork needs 3 anchors, has 2",
1837 &["2"],
1838 0,
1839 "Pitchfork has 2 of 3 anchors",
1840 ),
1841 (
1842 current.replacen("\"id\": \"2\"", "\"id\": \"1\"", 1),
1843 "duplicate drawing id \"1\"",
1844 &["1"],
1845 1,
1846 "duplicate 1",
1847 ),
1848 (
1849 v1_json.replacen("Trend Line", "Newer Tool", 1).replacen(
1851 "Fib Retracement",
1852 "fib retracement",
1853 1,
1854 ),
1855 "unknown drawing tool \"Newer Tool\"",
1856 &["2", "3", "4", "5"],
1857 0,
1858 "unknown tool Newer Tool",
1859 ),
1860 ];
1861 let reason = |skipped: &SkippedDrawing| match &skipped.error {
1862 SkipReason::UnknownTool(error) => format!("unknown tool {}", error.input),
1863 SkipReason::Cursor { tool } => format!("cursor {tool:?}"),
1864 SkipReason::MissingAnchors {
1865 tool,
1866 anchors,
1867 needed,
1868 } => format!("{tool:?} has {anchors} of {needed} anchors"),
1869 SkipReason::DuplicateId { id } => format!("duplicate {id}"),
1870 SkipReason::Invalid(_) => "invalid".into(),
1871 };
1872 for (json, error, loaded, index, expected) in unreadable {
1873 let (storage, skipped) = DrawingStorage::from_json(&json).unwrap();
1874 let ids: Vec<_> = storage.drawings().iter().map(|d| d.id.as_str()).collect();
1875 assert_eq!(ids, loaded);
1876 assert_eq!(skipped.len(), 1);
1877 assert_eq!(skipped[0].index, index);
1878 assert_eq!(reason(&skipped[0]), expected, "{skipped:?}");
1879 assert_eq!(skipped[0].id.as_deref(), Some("1"));
1880 assert!(skipped[0].error.to_string().contains(error), "{skipped:?}");
1881 assert!(storage.to_json().is_ok(), "{error}");
1882 }
1883
1884 let paintbrush = current.replace("\"TrendLine\"", "\"Paintbrush\"");
1886 let (storage, skipped) = DrawingStorage::from_json(&paintbrush).unwrap();
1887 assert!(skipped.is_empty(), "{skipped:?}");
1888 assert_eq!(
1889 storage.drawings()[1].drawing.tool_type,
1890 DrawingToolType::Brush
1891 );
1892
1893 let hidden = current.replacen("\"show_on_all\": true", "\"show_on_all\": false", 1);
1895 let (storage, skipped) = DrawingStorage::from_json(&hidden).unwrap();
1896 assert!(skipped.is_empty(), "{skipped:?}");
1897 assert!(
1898 storage.drawings()[0]
1899 .drawing
1900 .timeframe_visibility
1901 .show_on_all
1902 );
1903 let ranged = current.replacen(
1905 "\"show_on_all\": true",
1906 "\"show_on_all\": false, \"ranges\": {\"Hour\": {\"start\": 1, \"end\": 4}}",
1907 1,
1908 );
1909 let (storage, skipped) = DrawingStorage::from_json(&ranged).unwrap();
1910 assert!(skipped.is_empty(), "{skipped:?}");
1911 let visibility = &storage.drawings()[0].drawing.timeframe_visibility;
1912 assert!(!visibility.show_on_all);
1913 assert!(visibility.is_visible_on("2h") && !visibility.is_visible_on("1D"));
1914
1915 let error = DrawingStorage::from_json(¤t.replace("\"version\": 6", "\"version\": 7"))
1916 .unwrap_err();
1917 assert!(
1918 matches!(&error, LoadError::UnsupportedVersion { found: 7, supported } if *supported == (1..=6)),
1919 "{error}"
1920 );
1921 }
1922
1923 #[test]
1924 fn to_json_refuses_what_the_next_load_would_lose() {
1925 fn first(storage: &mut DrawingStorage) -> &mut StoredDrawing {
1926 &mut storage.drawings[0]
1927 }
1928 type Spoil = fn(&mut DrawingStorage);
1929 let cases: [(Spoil, &str); 26] = [
1930 (
1931 |s| {
1932 first(s)
1933 .drawing
1934 .image
1935 .get_or_insert_with(Default::default)
1936 .size = Some([f32::NAN, 1.0])
1937 },
1938 "invalid image.size",
1939 ),
1940 (
1941 |s| {
1942 first(s)
1943 .drawing
1944 .image
1945 .get_or_insert_with(Default::default)
1946 .size = Some([0.0, 1.0])
1947 },
1948 "invalid image.size",
1949 ),
1950 (
1951 |s| {
1952 first(s)
1953 .drawing
1954 .pitchfork
1955 .get_or_insert_with(Default::default)
1956 .levels[0]
1957 .width = Some(f32::NAN)
1958 },
1959 "invalid pitchfork.levels[0].width",
1960 ),
1961 (
1962 |s| {
1963 first(s)
1964 .drawing
1965 .pitchfork
1966 .get_or_insert_with(Default::default)
1967 .levels[0]
1968 .width = Some(0.0)
1969 },
1970 "invalid pitchfork.levels[0].width",
1971 ),
1972 (
1973 |s| first(s).drawing.position_sizing.leverage = f64::NAN,
1974 "invalid position_sizing",
1975 ),
1976 (
1977 |s| first(s).drawing.screen_anchor = Some([f32::NAN, 0.5]),
1978 "invalid screen_anchor",
1979 ),
1980 (
1981 |s| first(s).points[0].ts_ms = i64::MAX,
1982 "anchor 0 has no time mapping",
1983 ),
1984 (
1985 |s| first(s).points[0].subms_ns = 1_000_000,
1986 "anchor 0 has no time mapping",
1987 ),
1988 (
1989 |s| first(s).drawing.stroke_width = f32::NAN,
1990 "invalid stroke_width",
1991 ),
1992 (
1993 |s| first(s).drawing.font_size = f32::INFINITY,
1994 "invalid font_size",
1995 ),
1996 (
1997 |s| first(s).drawing.stroke_width = 3e38,
1998 "invalid stroke_width",
1999 ),
2000 (|s| first(s).drawing.font_size = 0.0, "invalid font_size"),
2001 (
2002 |s| first(s).points[1].price = f64::NAN,
2003 "invalid points[1].price",
2004 ),
2005 (
2006 |s| {
2007 first(s).drawing.position_size = Some(PositionSize::RiskPercent {
2008 account_size: 25_000.0,
2009 percent: f64::NEG_INFINITY,
2010 })
2011 },
2012 "invalid position_size",
2013 ),
2014 (
2015 |s| first(s).drawing.fib_config.as_mut().unwrap().level_width = 0.0,
2016 "invalid fib_config.level_width",
2017 ),
2018 (
2019 |s| first(s).drawing.fib_config.as_mut().unwrap().level_width = f32::NAN,
2020 "invalid fib_config.level_width",
2021 ),
2022 (
2023 |s| first(s).drawing.fib_config.as_mut().unwrap().level_width = 101.0,
2024 "invalid fib_config.level_width",
2025 ),
2026 (
2027 |s| first(s).drawing.fib_config.as_mut().unwrap().levels[0].value = f32::NAN,
2028 "invalid fib_config.levels[0].value",
2029 ),
2030 (
2031 |s| {
2032 first(s)
2033 .drawing
2034 .pitchfork
2035 .get_or_insert_with(Default::default)
2036 .levels[0]
2037 .value = f32::NAN
2038 },
2039 "invalid pitchfork.levels[0].value",
2040 ),
2041 (
2042 |s| {
2043 first(s)
2044 .drawing
2045 .pitchfork
2046 .get_or_insert_with(Default::default)
2047 .levels[0]
2048 .value = 3e38
2049 },
2050 "invalid pitchfork.levels[0].value",
2051 ),
2052 (
2053 |s| {
2054 first(s)
2055 .drawing
2056 .gann
2057 .get_or_insert_with(Default::default)
2058 .price_levels[0]
2059 .value = f32::INFINITY
2060 },
2061 "invalid gann.price_levels[0].value",
2062 ),
2063 (
2064 |s| {
2065 first(s)
2066 .drawing
2067 .gann
2068 .get_or_insert_with(Default::default)
2069 .time_levels[0]
2070 .value = f32::NAN
2071 },
2072 "invalid gann.time_levels[0].value",
2073 ),
2074 (
2075 |s| {
2076 first(s)
2077 .drawing
2078 .ghost_feed
2079 .get_or_insert_with(Default::default)
2080 .average_range_ticks = f64::NAN
2081 },
2082 "invalid ghost_feed",
2083 ),
2084 (|s| s.drawings.push(s.drawings[0].clone()), "duplicate id"),
2085 (|s| first(s).points.truncate(1), "Trend Line needs 2"),
2086 (
2087 |s| first(s).drawing.tool_type = DrawingToolType::Eraser,
2088 "a cursor",
2089 ),
2090 ];
2091 for (spoil, expected) in cases {
2092 let mut storage = DrawingStorage::new("AAPL");
2093 storage.add_drawing(drawing_to_stored(
2094 &non_default_drawing(DrawingToolType::TrendLine),
2095 bar_to_time,
2096 ));
2097 spoil(&mut storage);
2098 let got = match storage.to_json() {
2099 Err(SaveError::NonFinite { id, field }) => format!("{id}: invalid {field}"),
2100 Err(SaveError::DuplicateId { id }) => format!("{id}: duplicate id"),
2101 Err(SaveError::Cursor { id }) => format!("{id}: a cursor"),
2102 Err(SaveError::UnmappedAnchor { id, point }) => {
2103 format!("{id}: anchor {point} has no time mapping")
2104 }
2105 Err(SaveError::MissingAnchors {
2106 id, tool, needed, ..
2107 }) => {
2108 format!("{id}: {tool} needs {needed}")
2109 }
2110 other => panic!("{expected}: {:?}", other.map(drop)),
2111 };
2112 assert_eq!(got, format!("7: {expected}"));
2113 }
2114 }
2115
2116 #[test]
2117 fn test_remove_drawing() {
2118 let mut storage = DrawingStorage::new("AAPL");
2119 storage.add_drawing(StoredDrawing::new("1", Drawing::default()));
2120 storage.add_drawing(StoredDrawing::new("2", Drawing::default()));
2121
2122 assert!(storage.remove_drawing("1"));
2123 assert_eq!(storage.drawings().len(), 1);
2124 assert!(!storage.remove_drawing("1")); }
2126
2127 #[test]
2128 fn adding_an_existing_id_replaces_the_drawing_and_round_trips() {
2129 let mut storage = DrawingStorage::new("AAPL");
2130 for (id, tool, created_at) in [
2131 ("1", DrawingToolType::TrendLine, 10),
2132 ("2", DrawingToolType::Ray, 20),
2133 ("1", DrawingToolType::HorizontalLine, 30),
2134 ] {
2135 let mut stored = StoredDrawing::new(id, Drawing::new(0, tool));
2136 stored.points = vec![StoredCoord::new(T0, 100.0), StoredCoord::new(T0, 110.0)];
2137 stored.created_at = created_at;
2138 storage.add_drawing(stored);
2139 }
2140 let (loaded, skipped) = DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
2141 assert!(skipped.is_empty(), "{skipped:?}");
2142 let got: Vec<_> = loaded
2143 .drawings()
2144 .iter()
2145 .map(|d| (d.id.as_str(), d.drawing.tool_type, d.created_at))
2146 .collect();
2147 assert_eq!(
2148 got,
2149 [
2150 ("1", DrawingToolType::HorizontalLine, 10),
2151 ("2", DrawingToolType::Ray, 20)
2152 ]
2153 );
2154 }
2155}