1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::model::Bar;
5use crate::signal::TriggerAction;
6
7pub mod scene;
10pub use scene::{
11 scene_from_artifacts, Axis, AxisKind, LineStyle, Pane, Scene, SceneObject, SceneObjectKind,
12};
13
14mod scene_svg;
16pub use scene_svg::{render_scene_svg, Theme};
17
18#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21pub struct ChartRenderData {
22 pub title: String,
23 pub bars: Vec<ChartBarData>,
24 pub series: Vec<ChartSeries>,
25 pub zones: Vec<ChartZoneData>,
26 pub markers: Vec<ChartMarkerData>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
31pub struct ChartBarData {
32 pub timestamp: i64,
33 pub open: f64,
34 pub high: f64,
35 pub low: f64,
36 pub close: f64,
37 pub volume: f64,
38}
39
40impl From<&Bar> for ChartBarData {
41 fn from(b: &Bar) -> Self {
42 Self {
43 timestamp: b.timestamp,
44 open: b.open,
45 high: b.high,
46 low: b.low,
47 close: b.close,
48 volume: b.volume,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55pub struct ChartSeries {
56 pub name: String,
57 pub color: String,
58 pub points: Vec<(i64, f64)>,
59}
60
61#[derive(Debug, Clone, PartialEq)]
62#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
63pub struct ChartZoneData {
64 pub name: String,
65 pub price_top: f64,
66 pub price_bottom: f64,
67 pub color: String,
68}
69
70#[derive(Debug, Clone, PartialEq)]
71#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
72pub struct ChartMarkerData {
73 pub timestamp: i64,
74 pub price: f64,
75 pub label: String,
76 pub action: TriggerAction,
77}
78
79pub(crate) fn escape_xml(s: &str) -> String {
80 s.replace('&', "&")
81 .replace('<', "<")
82 .replace('>', ">")
83 .replace('"', """)
84 .replace('\'', "'")
85}
86
87pub fn sanitize_color(c: &str) -> String {
111 let trimmed = c.trim();
112 if is_hex_literal(trimmed) || is_css_variable(trimmed) {
113 trimmed.to_string()
114 } else {
115 "#29b6f6".to_string()
116 }
117}
118
119fn is_hex_literal(c: &str) -> bool {
121 (c.len() == 4 || c.len() == 7 || c.len() == 9)
122 && c.starts_with('#')
123 && c[1..].chars().all(|ch| ch.is_ascii_hexdigit())
124}
125
126fn is_css_variable(c: &str) -> bool {
128 let Some(inner) = c.strip_prefix("var(").and_then(|r| r.strip_suffix(')')) else {
129 return false;
130 };
131 let name = inner.trim();
132 name.starts_with("--")
133 && name.len() > 2
134 && name[2..]
135 .chars()
136 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
137}
138
139pub fn render_chart_svg(data: &ChartRenderData, width: u32, height: u32) -> String {
141 let safe_title = escape_xml(&data.title);
142
143 if data.bars.is_empty() {
144 return format!(
145 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}"><text x="10" y="20" fill="red">{}</text></svg>"##,
146 width, height, safe_title
147 );
148 }
149
150 let min_price = data.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
151 let max_price = data.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
152 let price_range = (max_price - min_price).max(1e-8);
153
154 let margin_top = 40.0;
155 let margin_bottom = 30.0;
156 let margin_left = 20.0;
157 let margin_right = 60.0;
158
159 let chart_w = width as f64 - margin_left - margin_right;
160 let chart_h = height as f64 - margin_top - margin_bottom;
161
162 let to_y = |p: f64| -> f64 { margin_top + (1.0 - (p - min_price) / price_range) * chart_h };
163
164 let n = data.bars.len();
165 let bar_w = (chart_w / n as f64).max(1.0);
166
167 let min_ts = data.bars.first().map(|b| b.timestamp).unwrap_or(0);
169 let max_ts = data.bars.last().map(|b| b.timestamp).unwrap_or(1);
170 let ts_range = (max_ts - min_ts).max(1) as f64;
171
172 let get_x_for_ts = |ts: i64| -> f64 {
173 if n == 1 {
174 return margin_left + chart_w / 2.0;
175 }
176 let ratio = (ts - min_ts) as f64 / ts_range;
177 margin_left + ratio * chart_w
178 };
179
180 let mut svg = String::new();
181 svg.push_str(&format!(
182 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" style="background-color:#141824;font-family:sans-serif;">"##,
183 width, height
184 ));
185
186 svg.push_str(&format!(
188 r##"<text x="20" y="25" fill="#e0e6ed" font-size="16" font-weight="bold">{}</text>"##,
189 safe_title
190 ));
191
192 for zone in &data.zones {
194 let y_top = to_y(zone.price_top);
195 let y_bot = to_y(zone.price_bottom);
196 let zone_h = (y_bot - y_top).abs().max(1.0);
197 let min_y = y_top.min(y_bot);
198 let color = sanitize_color(&zone.color);
199 let safe_name = escape_xml(&zone.name);
200
201 svg.push_str(&format!(
202 r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" opacity="0.2"/>"##,
203 margin_left, min_y, chart_w, zone_h, color
204 ));
205 svg.push_str(&format!(
206 r##"<text x="{}" y="{}" fill="{}" font-size="10" opacity="0.8">{}</text>"##,
207 margin_left + 5.0,
208 min_y + 12.0,
209 color,
210 safe_name
211 ));
212 }
213
214 for (i, b) in data.bars.iter().enumerate() {
216 let x = margin_left + i as f64 * bar_w + bar_w / 2.0;
217 let y_high = to_y(b.high);
218 let y_low = to_y(b.low);
219 let y_open = to_y(b.open);
220 let y_close = to_y(b.close);
221
222 let is_bull = b.close >= b.open;
223 let color = if is_bull { "#26a69a" } else { "#ef5350" };
224
225 svg.push_str(&format!(
227 r##"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"##,
228 x, y_high, x, y_low, color
229 ));
230
231 let body_top = y_open.min(y_close);
233 let body_h = (y_open - y_close).abs().max(1.0);
234 let body_w = (bar_w * 0.7).max(1.0);
235 svg.push_str(&format!(
236 r##"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"/>"##,
237 x - body_w / 2.0,
238 body_top,
239 body_w,
240 body_h,
241 color
242 ));
243 }
244
245 for s in &data.series {
247 if s.points.is_empty() {
248 continue;
249 }
250 let color = sanitize_color(&s.color);
251 let points_str: Vec<String> = s
252 .points
253 .iter()
254 .map(|&(ts, val)| format!("{:.1},{:.1}", get_x_for_ts(ts), to_y(val)))
255 .collect();
256
257 svg.push_str(&format!(
258 r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="1.5"/>"##,
259 points_str.join(" "),
260 color
261 ));
262 }
263
264 for m in &data.markers {
266 let x = get_x_for_ts(m.timestamp);
267 let y = to_y(m.price);
268 let m_color = match m.action {
269 TriggerAction::Buy => "#00e676",
270 TriggerAction::Sell => "#ff1744",
271 TriggerAction::Exit => "#ff9100",
272 TriggerAction::Hold => "#29b6f6",
273 };
274 let safe_label = escape_xml(&m.label);
275
276 svg.push_str(&format!(
277 r##"<circle cx="{:.1}" cy="{:.1}" r="5" fill="{}"/>"##,
278 x, y, m_color
279 ));
280 svg.push_str(&format!(
281 r##"<text x="{:.1}" y="{:.1}" fill="{}" font-size="9" font-weight="bold">{}</text>"##,
282 x + 7.0,
283 y + 3.0,
284 m_color,
285 safe_label
286 ));
287 }
288
289 svg.push_str("</svg>");
290 svg
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_svg_xml_escaping_and_color_sanitization() {
299 let bars = vec![
300 ChartBarData {
301 timestamp: 1000,
302 open: 100.0,
303 high: 105.0,
304 low: 95.0,
305 close: 104.0,
306 volume: 1000.0,
307 },
308 ChartBarData {
309 timestamp: 2000,
310 open: 104.0,
311 high: 108.0,
312 low: 102.0,
313 close: 107.0,
314 volume: 1500.0,
315 },
316 ];
317 let data = ChartRenderData {
318 title: "<Chart> & \"Test\"".to_string(),
319 bars,
320 zones: vec![ChartZoneData {
321 name: "Zone <A>".to_string(),
322 price_top: 108.0,
323 price_bottom: 105.0,
324 color: "invalid_color_script".to_string(),
325 }],
326 series: vec![ChartSeries {
327 name: "SMA 20".to_string(),
328 points: vec![(1000, 98.0), (2000, 103.0)],
329 color: "#00ff00".to_string(),
330 }],
331 markers: vec![
332 ChartMarkerData {
333 timestamp: 1000,
334 price: 100.0,
335 label: "Buy & Hold <NOW>".to_string(),
336 action: TriggerAction::Buy,
337 },
338 ChartMarkerData {
339 timestamp: 2000,
340 price: 107.0,
341 label: "Exit".to_string(),
342 action: TriggerAction::Exit,
343 },
344 ],
345 };
346
347 let svg = render_chart_svg(&data, 800, 400);
348
349 assert!(svg.contains("<Chart> & "Test""));
351 assert!(svg.contains("Zone <A>"));
352 assert!(svg.contains("Buy & Hold <NOW>"));
353
354 assert!(svg.contains("#29b6f6"));
356
357 assert!(svg.contains("<polyline points="));
359
360 assert!(svg.contains("<circle cx=\"20.0\""));
362 assert!(svg.contains("<circle cx=\"740.0\""));
363 }
364}
365
366#[cfg(test)]
367mod color_tests {
368 use super::sanitize_color;
369
370 #[test]
371 fn accepts_hex_literals() {
372 assert_eq!(sanitize_color("#abc"), "#abc");
373 assert_eq!(sanitize_color("#1a2B3c"), "#1a2B3c");
374 assert_eq!(sanitize_color(" #ffffff "), "#ffffff");
375 }
376
377 #[test]
378 fn accepts_css_variables() {
379 assert_eq!(
380 sanitize_color("var(--chart-bullish)"),
381 "var(--chart-bullish)"
382 );
383 assert_eq!(sanitize_color("var(--x_1)"), "var(--x_1)");
384 }
385
386 #[test]
387 fn rejects_everything_else() {
388 for hostile in [
390 "red",
391 "rgb(1,2,3)",
392 "var(--x, #fff)",
393 "var(--x);fill:url(#y)",
394 "var(--x)\"onload=\"alert(1)",
395 "url(#gradient)",
396 "#12345",
397 "var(--)",
398 "var(x)",
399 ] {
400 assert_eq!(
401 sanitize_color(hostile),
402 "#29b6f6",
403 "must reject {hostile:?}"
404 );
405 }
406 }
407}