1use chrono::Datelike;
2
3use crate::backtest_app::runner::BacktestReport;
4use crate::charting::scene::{
5 Bar, BarSeries, Candle, CandleSeries, ChartScene, EpochMs, HoverModel, LinePoint, LineSeries,
6 Marker, MarkerSeries, MarkerShape, Pane, Series, TooltipModel, ValueFormatter, Viewport,
7 YAxisSpec,
8};
9use crate::charting::style::{ChartTheme, RgbColor};
10use crate::visualization::service::VisualizationService;
11use crate::visualization::types::{DashboardSnapshot, SignalKind};
12
13const PRICE: RgbColor = RgbColor::new(120, 220, 180);
14const LIQ_BUY: RgbColor = RgbColor::new(255, 140, 90);
15const LIQ_OTHER: RgbColor = RgbColor::new(255, 210, 100);
16const ENTRY: RgbColor = RgbColor::new(90, 170, 255);
17const TAKE_PROFIT: RgbColor = RgbColor::new(80, 220, 140);
18const STOP_LOSS: RgbColor = RgbColor::new(255, 90, 90);
19const OPEN_AT_END: RgbColor = RgbColor::new(240, 220, 120);
20const EQUITY: RgbColor = RgbColor::new(120, 180, 255);
21const VOLUME_UP: RgbColor = RgbColor::new(70, 150, 110);
22const VOLUME_DOWN: RgbColor = RgbColor::new(160, 90, 90);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum MarketTimeframe {
26 Tick1s,
27 Minute1m,
28 Minute3m,
29 Minute5m,
30 Minute15m,
31 Minute30m,
32 Hour1h,
33 Hour4h,
34 Week1w,
35 Day1d,
36 Month1mo,
37}
38
39impl MarketTimeframe {
40 pub fn label(self) -> &'static str {
41 match self {
42 Self::Tick1s => "1s",
43 Self::Minute1m => "1m",
44 Self::Minute3m => "3m",
45 Self::Minute5m => "5m",
46 Self::Minute15m => "15m",
47 Self::Minute30m => "30m",
48 Self::Hour1h => "1h",
49 Self::Hour4h => "4h",
50 Self::Week1w => "1w",
51 Self::Day1d => "1d",
52 Self::Month1mo => "1mo",
53 }
54 }
55
56 pub fn all() -> [Self; 11] {
57 [
58 Self::Tick1s,
59 Self::Minute1m,
60 Self::Minute3m,
61 Self::Minute5m,
62 Self::Minute15m,
63 Self::Minute30m,
64 Self::Hour1h,
65 Self::Hour4h,
66 Self::Week1w,
67 Self::Day1d,
68 Self::Month1mo,
69 ]
70 }
71
72 pub fn from_interval_label(value: &str) -> Option<Self> {
73 Some(match value {
74 "1s" => Self::Tick1s,
75 "1m" => Self::Minute1m,
76 "3m" => Self::Minute3m,
77 "5m" => Self::Minute5m,
78 "15m" => Self::Minute15m,
79 "30m" => Self::Minute30m,
80 "1h" => Self::Hour1h,
81 "4h" => Self::Hour4h,
82 "1w" => Self::Week1w,
83 "1d" => Self::Day1d,
84 "1mo" => Self::Month1mo,
85 _ => return None,
86 })
87 }
88
89 pub fn rank(self) -> usize {
90 match self {
91 Self::Tick1s => 0,
92 Self::Minute1m => 1,
93 Self::Minute3m => 2,
94 Self::Minute5m => 3,
95 Self::Minute15m => 4,
96 Self::Minute30m => 5,
97 Self::Hour1h => 6,
98 Self::Hour4h => 7,
99 Self::Week1w => 8,
100 Self::Day1d => 9,
101 Self::Month1mo => 10,
102 }
103 }
104}
105
106pub fn market_scene_from_snapshot(snapshot: &DashboardSnapshot) -> ChartScene {
107 market_scene_from_snapshot_with_timeframe(snapshot, MarketTimeframe::Tick1s)
108}
109
110pub fn market_scene_from_snapshot_with_timeframe(
111 snapshot: &DashboardSnapshot,
112 timeframe: MarketTimeframe,
113) -> ChartScene {
114 let effective_timeframe = snapshot
115 .market_series
116 .kline_interval
117 .as_deref()
118 .and_then(MarketTimeframe::from_interval_label)
119 .filter(|source| source.rank() > timeframe.rank())
120 .unwrap_or(timeframe);
121 let mut price_series = Vec::new();
122 if !snapshot.market_series.book_tickers.is_empty() {
123 price_series.extend(sampled_mid_price_segments(snapshot, 2_400, 60_000, 1));
124 }
125 let display_klines =
126 aggregate_klines_for_timeframe(&snapshot.market_series.klines, effective_timeframe);
127 if !display_klines.is_empty() {
128 price_series.push(Series::Candles(CandleSeries {
129 name: "candles".to_string(),
130 up_color: None,
131 down_color: None,
132 candles: display_klines
133 .iter()
134 .map(|row| Candle {
135 open_time_ms: EpochMs::from(row.open_time_ms),
136 close_time_ms: EpochMs::from(row.close_time_ms),
137 open: row.open,
138 high: row.high,
139 low: row.low,
140 close: row.close,
141 })
142 .collect(),
143 }));
144 } else if price_series.is_empty() {
145 price_series.extend(sampled_mid_price_segments(snapshot, 2_400, 60_000, 2));
146 }
147
148 let mut markers = snapshot
149 .market_series
150 .liquidations
151 .iter()
152 .map(|row| Marker {
153 label: row.force_side.clone(),
154 time_ms: EpochMs::from(row.event_time_ms),
155 value: row.price,
156 color: if row.force_side == "BUY" {
157 LIQ_BUY
158 } else {
159 LIQ_OTHER
160 },
161 size: ((row.notional.max(1.0)).log10().clamp(0.0, 7.0) as i32) + 3,
162 shape: MarkerShape::Circle,
163 })
164 .collect::<Vec<_>>();
165
166 if let Some(report) = snapshot
167 .selected_report
168 .as_ref()
169 .filter(|report| report.instrument == snapshot.symbol)
170 {
171 markers.extend(
172 VisualizationService::signal_markers(&report.trades)
173 .into_iter()
174 .map(|marker| Marker {
175 label: marker.label,
176 time_ms: EpochMs::from(marker.time_ms),
177 value: marker.price,
178 color: signal_color(marker.kind),
179 size: 8,
180 shape: MarkerShape::Cross,
181 }),
182 );
183 }
184
185 if !markers.is_empty() {
186 price_series.push(Series::Markers(MarkerSeries {
187 name: "signals".to_string(),
188 markers,
189 }));
190 }
191
192 let mut panes = vec![Pane {
193 id: "market".to_string(),
194 title: Some(format!("Market ({})", effective_timeframe.label())),
195 weight: 4,
196 y_axis: usdt_axis(2, false),
197 series: price_series,
198 }];
199
200 if !display_klines.is_empty() {
201 panes.push(Pane {
202 id: "volume".to_string(),
203 title: Some(format!("Volume ({})", effective_timeframe.label())),
204 weight: 1,
205 y_axis: compact_axis("Volume", 1, true),
206 series: vec![Series::Bars(BarSeries {
207 name: "volume".to_string(),
208 color: VOLUME_UP,
209 bars: display_klines
210 .iter()
211 .map(|row| Bar {
212 open_time_ms: EpochMs::from(row.open_time_ms),
213 close_time_ms: EpochMs::from(row.close_time_ms),
214 value: row.volume,
215 color: Some(if row.close >= row.open {
216 VOLUME_UP
217 } else {
218 VOLUME_DOWN
219 }),
220 })
221 .collect(),
222 })],
223 });
224 }
225
226 ChartScene {
227 title: format!(
228 "{} | liq {} | ticks {} | {} bars {}",
229 snapshot.symbol,
230 snapshot.dataset_summary.liquidation_events,
231 snapshot.dataset_summary.book_ticker_events,
232 effective_timeframe.label(),
233 display_klines.len(),
234 ),
235 time_label_format: "%m-%d %H:%M".to_string(),
236 theme: ChartTheme::default(),
237 viewport: focused_market_viewport(snapshot),
238 hover: Some(
239 TooltipModel {
240 title: "Market".to_string(),
241 sections: Vec::new(),
242 }
243 .into(),
244 ),
245 panes,
246 }
247}
248
249fn focused_market_viewport(snapshot: &DashboardSnapshot) -> Viewport {
250 let Some(report) = snapshot
251 .selected_report
252 .as_ref()
253 .filter(|report| report.instrument == snapshot.symbol && !report.trades.is_empty())
254 else {
255 return Viewport::default();
256 };
257
258 let mut min_time = i64::MAX;
259 let mut max_time = i64::MIN;
260 for trade in &report.trades {
261 min_time = min_time.min(trade.entry_time.timestamp_millis());
262 max_time = max_time.max(
263 trade
264 .exit_time
265 .map(|value| value.timestamp_millis())
266 .unwrap_or_else(|| trade.entry_time.timestamp_millis()),
267 );
268 }
269 if min_time > max_time {
270 return Viewport::default();
271 }
272 let span = (max_time - min_time).max(1);
273 if span > 25 * 60 * 1_000 {
274 return Viewport {
275 x_range: Some((
276 EpochMs::new(max_time.saturating_sub(25 * 60 * 1_000)),
277 EpochMs::new(max_time.saturating_add(2 * 60 * 1_000)),
278 )),
279 };
280 }
281 let padding = ((span as f64) * 0.35).round() as i64;
282 let padding = padding.max(5 * 60 * 1_000);
283 Viewport {
284 x_range: Some((
285 EpochMs::new(min_time.saturating_sub(padding)),
286 EpochMs::new(max_time.saturating_add(padding)),
287 )),
288 }
289}
290
291pub fn equity_scene_from_report(report: &BacktestReport) -> ChartScene {
292 let mut points = VisualizationService::equity_curve(report.starting_equity, &report.trades)
293 .into_iter()
294 .map(|point| LinePoint {
295 time_ms: EpochMs::from(point.time_ms),
296 value: point.equity,
297 })
298 .collect::<Vec<_>>();
299 if let Some(first) = points.first().cloned() {
300 points.insert(
301 0,
302 LinePoint {
303 time_ms: first.time_ms,
304 value: report.starting_equity,
305 },
306 );
307 }
308 ChartScene {
309 title: format!(
310 "Run #{} | ending equity {:.2} | net pnl {:.2}",
311 report.run_id.unwrap_or_default(),
312 report.ending_equity,
313 report.net_pnl,
314 ),
315 time_label_format: "%m-%d %H:%M".to_string(),
316 theme: ChartTheme::default(),
317 viewport: Viewport::default(),
318 hover: Some(
319 TooltipModel {
320 title: "Equity".to_string(),
321 sections: Vec::new(),
322 }
323 .into(),
324 ),
325 panes: vec![Pane {
326 id: "equity".to_string(),
327 title: Some("Equity".to_string()),
328 weight: 1,
329 y_axis: usdt_axis(2, false),
330 series: vec![Series::Line(LineSeries {
331 name: "equity".to_string(),
332 color: EQUITY,
333 width: 2,
334 points,
335 })],
336 }],
337 }
338}
339
340fn signal_color(kind: SignalKind) -> RgbColor {
341 match kind {
342 SignalKind::Entry => ENTRY,
343 SignalKind::TakeProfit => TAKE_PROFIT,
344 SignalKind::StopLoss => STOP_LOSS,
345 SignalKind::OpenAtEnd => OPEN_AT_END,
346 }
347}
348
349fn usdt_axis(decimals: u8, include_zero: bool) -> YAxisSpec {
350 YAxisSpec {
351 label: Some("USDT".to_string()),
352 formatter: ValueFormatter::Number {
353 decimals,
354 prefix: String::new(),
355 suffix: " USDT".to_string(),
356 },
357 include_zero,
358 }
359}
360
361fn compact_axis(label: &str, decimals: u8, include_zero: bool) -> YAxisSpec {
362 YAxisSpec {
363 label: Some(label.to_string()),
364 formatter: ValueFormatter::Compact {
365 decimals,
366 prefix: String::new(),
367 suffix: String::new(),
368 },
369 include_zero,
370 }
371}
372
373fn sampled_mid_price_segments(
374 snapshot: &DashboardSnapshot,
375 max_points: usize,
376 max_gap_ms: i64,
377 width: u32,
378) -> Vec<Series> {
379 let prices = VisualizationService::price_points(&snapshot.market_series);
380 if prices.is_empty() {
381 return Vec::new();
382 }
383 let stride = if prices.len() <= max_points || max_points == 0 {
384 1
385 } else {
386 (prices.len() / max_points).max(1)
387 };
388 let sampled = prices
389 .into_iter()
390 .step_by(stride)
391 .map(|point| LinePoint {
392 time_ms: EpochMs::from(point.time_ms),
393 value: point.price,
394 })
395 .collect::<Vec<_>>();
396 if sampled.is_empty() {
397 return Vec::new();
398 }
399
400 let mut segments = Vec::new();
401 let mut current = Vec::new();
402 for point in sampled {
403 let gap_too_large = current.last().is_some_and(|last: &LinePoint| {
404 point.time_ms.as_i64() - last.time_ms.as_i64() > max_gap_ms
405 });
406 if gap_too_large && current.len() >= 2 {
407 segments.push(Series::Line(LineSeries {
408 name: "mid-price".to_string(),
409 color: PRICE,
410 width,
411 points: std::mem::take(&mut current),
412 }));
413 }
414 current.push(point);
415 }
416 if current.len() >= 2 {
417 segments.push(Series::Line(LineSeries {
418 name: "mid-price".to_string(),
419 color: PRICE,
420 width,
421 points: current,
422 }));
423 }
424 segments
425}
426
427fn aggregate_klines_for_timeframe(
428 klines: &[crate::dataset::types::DerivedKlineRow],
429 timeframe: MarketTimeframe,
430) -> Vec<crate::dataset::types::DerivedKlineRow> {
431 match timeframe {
432 MarketTimeframe::Tick1s => klines.to_vec(),
433 MarketTimeframe::Minute1m => aggregate_klines(klines, bucket_start_minute),
434 MarketTimeframe::Minute3m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 3)),
435 MarketTimeframe::Minute5m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 5)),
436 MarketTimeframe::Minute15m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 15)),
437 MarketTimeframe::Minute30m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 30)),
438 MarketTimeframe::Hour1h => aggregate_klines(klines, |ms| bucket_start_hours(ms, 1)),
439 MarketTimeframe::Hour4h => aggregate_klines(klines, |ms| bucket_start_hours(ms, 4)),
440 MarketTimeframe::Week1w => aggregate_klines(klines, bucket_start_week),
441 MarketTimeframe::Day1d => aggregate_klines(klines, bucket_start_day),
442 MarketTimeframe::Month1mo => aggregate_klines(klines, bucket_start_month),
443 }
444}
445
446fn aggregate_klines(
447 klines: &[crate::dataset::types::DerivedKlineRow],
448 bucket_start: fn(i64) -> i64,
449) -> Vec<crate::dataset::types::DerivedKlineRow> {
450 if klines.is_empty() {
451 return Vec::new();
452 }
453 let mut aggregated = Vec::new();
454 let mut current_bucket = bucket_start(klines[0].open_time_ms);
455 let mut current = klines[0].clone();
456 current.open_time_ms = current_bucket;
457
458 for row in klines.iter().skip(1) {
459 let next_bucket = bucket_start(row.open_time_ms);
460 if next_bucket != current_bucket {
461 aggregated.push(current);
462 current_bucket = next_bucket;
463 current = row.clone();
464 current.open_time_ms = current_bucket;
465 continue;
466 }
467 current.close_time_ms = row.close_time_ms;
468 current.high = current.high.max(row.high);
469 current.low = current.low.min(row.low);
470 current.close = row.close;
471 current.volume += row.volume;
472 current.quote_volume += row.quote_volume;
473 current.trade_count += row.trade_count;
474 }
475 aggregated.push(current);
476 aggregated
477}
478
479fn bucket_start_minute(ms: i64) -> i64 {
480 (ms / 60_000) * 60_000
481}
482
483fn bucket_start_minutes(ms: i64, minutes: i64) -> i64 {
484 let bucket_ms = minutes * 60_000;
485 (ms / bucket_ms) * bucket_ms
486}
487
488fn bucket_start_hours(ms: i64, hours: i64) -> i64 {
489 let bucket_ms = hours * 60 * 60_000;
490 (ms / bucket_ms) * bucket_ms
491}
492
493fn bucket_start_day(ms: i64) -> i64 {
494 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
495 .and_then(|dt| {
496 dt.date_naive()
497 .and_hms_opt(0, 0, 0)
498 .map(|naive| naive.and_utc().timestamp_millis())
499 })
500 .unwrap_or(ms)
501}
502
503fn bucket_start_week(ms: i64) -> i64 {
504 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
505 .and_then(|dt| {
506 let date = dt.date_naive();
507 let weekday_offset = i64::from(date.weekday().num_days_from_monday());
508 date.checked_sub_days(chrono::Days::new(weekday_offset as u64))
509 .and_then(|start| start.and_hms_opt(0, 0, 0))
510 .map(|naive| naive.and_utc().timestamp_millis())
511 })
512 .unwrap_or(ms)
513}
514
515fn bucket_start_month(ms: i64) -> i64 {
516 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
517 .and_then(|dt| {
518 chrono::NaiveDate::from_ymd_opt(dt.year(), dt.month(), 1)
519 .and_then(|date| date.and_hms_opt(0, 0, 0))
520 .map(|naive| naive.and_utc().timestamp_millis())
521 })
522 .unwrap_or(ms)
523}
524
525impl From<TooltipModel> for HoverModel {
526 fn from(tooltip: TooltipModel) -> Self {
527 Self {
528 crosshair: None,
529 tooltip: Some(tooltip),
530 }
531 }
532}