1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::model::Bar;
5use crate::signal::TriggerAction;
6
7pub mod scene;
10pub use scene::{Axis, AxisKind, LineStyle, Pane, Scene, SceneObject, SceneObjectKind};
11
12#[derive(Debug, Clone, PartialEq)]
14#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
15pub struct ChartRenderData {
16 pub title: String,
17 pub bars: Vec<ChartBarData>,
18 pub series: Vec<ChartSeries>,
19 pub zones: Vec<ChartZoneData>,
20 pub markers: Vec<ChartMarkerData>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25pub struct ChartBarData {
26 pub timestamp: i64,
27 pub open: f64,
28 pub high: f64,
29 pub low: f64,
30 pub close: f64,
31 pub volume: f64,
32}
33
34impl From<&Bar> for ChartBarData {
35 fn from(b: &Bar) -> Self {
36 Self {
37 timestamp: b.timestamp,
38 open: b.open,
39 high: b.high,
40 low: b.low,
41 close: b.close,
42 volume: b.volume,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
48#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
49pub struct ChartSeries {
50 pub name: String,
51 pub color: String,
52 pub points: Vec<(i64, f64)>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
57pub struct ChartZoneData {
58 pub name: String,
59 pub price_top: f64,
60 pub price_bottom: f64,
61 pub color: String,
62}
63
64#[derive(Debug, Clone, PartialEq)]
65#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
66pub struct ChartMarkerData {
67 pub timestamp: i64,
68 pub price: f64,
69 pub label: String,
70 pub action: TriggerAction,
71}
72
73fn escape_xml(s: &str) -> String {
74 s.replace('&', "&")
75 .replace('<', "<")
76 .replace('>', ">")
77 .replace('"', """)
78 .replace('\'', "'")
79}
80
81fn sanitize_color(c: &str) -> String {
82 let trimmed = c.trim();
83 if (trimmed.len() == 4 || trimmed.len() == 7)
84 && trimmed.starts_with('#')
85 && trimmed[1..].chars().all(|ch| ch.is_ascii_hexdigit())
86 {
87 trimmed.to_string()
88 } else {
89 "#29b6f6".to_string()
90 }
91}
92
93pub fn render_chart_svg(data: &ChartRenderData, width: u32, height: u32) -> String {
95 let safe_title = escape_xml(&data.title);
96
97 if data.bars.is_empty() {
98 return format!(
99 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}"><text x="10" y="20" fill="red">{}</text></svg>"##,
100 width, height, safe_title
101 );
102 }
103
104 let min_price = data.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
105 let max_price = data.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
106 let price_range = (max_price - min_price).max(1e-8);
107
108 let margin_top = 40.0;
109 let margin_bottom = 30.0;
110 let margin_left = 20.0;
111 let margin_right = 60.0;
112
113 let chart_w = width as f64 - margin_left - margin_right;
114 let chart_h = height as f64 - margin_top - margin_bottom;
115
116 let to_y = |p: f64| -> f64 { margin_top + (1.0 - (p - min_price) / price_range) * chart_h };
117
118 let n = data.bars.len();
119 let bar_w = (chart_w / n as f64).max(1.0);
120
121 let min_ts = data.bars.first().map(|b| b.timestamp).unwrap_or(0);
123 let max_ts = data.bars.last().map(|b| b.timestamp).unwrap_or(1);
124 let ts_range = (max_ts - min_ts).max(1) as f64;
125
126 let get_x_for_ts = |ts: i64| -> f64 {
127 if n == 1 {
128 return margin_left + chart_w / 2.0;
129 }
130 let ratio = (ts - min_ts) as f64 / ts_range;
131 margin_left + ratio * chart_w
132 };
133
134 let mut svg = String::new();
135 svg.push_str(&format!(
136 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" style="background-color:#141824;font-family:sans-serif;">"##,
137 width, height
138 ));
139
140 svg.push_str(&format!(
142 r##"<text x="20" y="25" fill="#e0e6ed" font-size="16" font-weight="bold">{}</text>"##,
143 safe_title
144 ));
145
146 for zone in &data.zones {
148 let y_top = to_y(zone.price_top);
149 let y_bot = to_y(zone.price_bottom);
150 let zone_h = (y_bot - y_top).abs().max(1.0);
151 let min_y = y_top.min(y_bot);
152 let color = sanitize_color(&zone.color);
153 let safe_name = escape_xml(&zone.name);
154
155 svg.push_str(&format!(
156 r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" opacity="0.2"/>"##,
157 margin_left, min_y, chart_w, zone_h, color
158 ));
159 svg.push_str(&format!(
160 r##"<text x="{}" y="{}" fill="{}" font-size="10" opacity="0.8">{}</text>"##,
161 margin_left + 5.0,
162 min_y + 12.0,
163 color,
164 safe_name
165 ));
166 }
167
168 for (i, b) in data.bars.iter().enumerate() {
170 let x = margin_left + i as f64 * bar_w + bar_w / 2.0;
171 let y_high = to_y(b.high);
172 let y_low = to_y(b.low);
173 let y_open = to_y(b.open);
174 let y_close = to_y(b.close);
175
176 let is_bull = b.close >= b.open;
177 let color = if is_bull { "#26a69a" } else { "#ef5350" };
178
179 svg.push_str(&format!(
181 r##"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"##,
182 x, y_high, x, y_low, color
183 ));
184
185 let body_top = y_open.min(y_close);
187 let body_h = (y_open - y_close).abs().max(1.0);
188 let body_w = (bar_w * 0.7).max(1.0);
189 svg.push_str(&format!(
190 r##"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"/>"##,
191 x - body_w / 2.0,
192 body_top,
193 body_w,
194 body_h,
195 color
196 ));
197 }
198
199 for s in &data.series {
201 if s.points.is_empty() {
202 continue;
203 }
204 let color = sanitize_color(&s.color);
205 let points_str: Vec<String> = s
206 .points
207 .iter()
208 .map(|&(ts, val)| format!("{:.1},{:.1}", get_x_for_ts(ts), to_y(val)))
209 .collect();
210
211 svg.push_str(&format!(
212 r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="1.5"/>"##,
213 points_str.join(" "),
214 color
215 ));
216 }
217
218 for m in &data.markers {
220 let x = get_x_for_ts(m.timestamp);
221 let y = to_y(m.price);
222 let m_color = match m.action {
223 TriggerAction::Buy => "#00e676",
224 TriggerAction::Sell => "#ff1744",
225 TriggerAction::Exit => "#ff9100",
226 TriggerAction::Hold => "#29b6f6",
227 };
228 let safe_label = escape_xml(&m.label);
229
230 svg.push_str(&format!(
231 r##"<circle cx="{:.1}" cy="{:.1}" r="5" fill="{}"/>"##,
232 x, y, m_color
233 ));
234 svg.push_str(&format!(
235 r##"<text x="{:.1}" y="{:.1}" fill="{}" font-size="9" font-weight="bold">{}</text>"##,
236 x + 7.0,
237 y + 3.0,
238 m_color,
239 safe_label
240 ));
241 }
242
243 svg.push_str("</svg>");
244 svg
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn test_svg_xml_escaping_and_color_sanitization() {
253 let bars = vec![
254 ChartBarData {
255 timestamp: 1000,
256 open: 100.0,
257 high: 105.0,
258 low: 95.0,
259 close: 104.0,
260 volume: 1000.0,
261 },
262 ChartBarData {
263 timestamp: 2000,
264 open: 104.0,
265 high: 108.0,
266 low: 102.0,
267 close: 107.0,
268 volume: 1500.0,
269 },
270 ];
271 let data = ChartRenderData {
272 title: "<Chart> & \"Test\"".to_string(),
273 bars,
274 zones: vec![ChartZoneData {
275 name: "Zone <A>".to_string(),
276 price_top: 108.0,
277 price_bottom: 105.0,
278 color: "invalid_color_script".to_string(),
279 }],
280 series: vec![ChartSeries {
281 name: "SMA 20".to_string(),
282 points: vec![(1000, 98.0), (2000, 103.0)],
283 color: "#00ff00".to_string(),
284 }],
285 markers: vec![
286 ChartMarkerData {
287 timestamp: 1000,
288 price: 100.0,
289 label: "Buy & Hold <NOW>".to_string(),
290 action: TriggerAction::Buy,
291 },
292 ChartMarkerData {
293 timestamp: 2000,
294 price: 107.0,
295 label: "Exit".to_string(),
296 action: TriggerAction::Exit,
297 },
298 ],
299 };
300
301 let svg = render_chart_svg(&data, 800, 400);
302
303 assert!(svg.contains("<Chart> & "Test""));
305 assert!(svg.contains("Zone <A>"));
306 assert!(svg.contains("Buy & Hold <NOW>"));
307
308 assert!(svg.contains("#29b6f6"));
310
311 assert!(svg.contains("<polyline points="));
313
314 assert!(svg.contains("<circle cx=\"20.0\""));
316 assert!(svg.contains("<circle cx=\"740.0\""));
317 }
318}