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 SignalKind::SignalExit => STOP_LOSS,
347 }
348}
349
350fn usdt_axis(decimals: u8, include_zero: bool) -> YAxisSpec {
351 YAxisSpec {
352 label: Some("USDT".to_string()),
353 formatter: ValueFormatter::Number {
354 decimals,
355 prefix: String::new(),
356 suffix: " USDT".to_string(),
357 },
358 include_zero,
359 }
360}
361
362fn compact_axis(label: &str, decimals: u8, include_zero: bool) -> YAxisSpec {
363 YAxisSpec {
364 label: Some(label.to_string()),
365 formatter: ValueFormatter::Compact {
366 decimals,
367 prefix: String::new(),
368 suffix: String::new(),
369 },
370 include_zero,
371 }
372}
373
374fn sampled_mid_price_segments(
375 snapshot: &DashboardSnapshot,
376 max_points: usize,
377 max_gap_ms: i64,
378 width: u32,
379) -> Vec<Series> {
380 let prices = VisualizationService::price_points(&snapshot.market_series);
381 if prices.is_empty() {
382 return Vec::new();
383 }
384 let stride = if prices.len() <= max_points || max_points == 0 {
385 1
386 } else {
387 (prices.len() / max_points).max(1)
388 };
389 let sampled = prices
390 .into_iter()
391 .step_by(stride)
392 .map(|point| LinePoint {
393 time_ms: EpochMs::from(point.time_ms),
394 value: point.price,
395 })
396 .collect::<Vec<_>>();
397 if sampled.is_empty() {
398 return Vec::new();
399 }
400
401 let mut segments = Vec::new();
402 let mut current = Vec::new();
403 for point in sampled {
404 let gap_too_large = current.last().is_some_and(|last: &LinePoint| {
405 point.time_ms.as_i64() - last.time_ms.as_i64() > max_gap_ms
406 });
407 if gap_too_large && current.len() >= 2 {
408 segments.push(Series::Line(LineSeries {
409 name: "mid-price".to_string(),
410 color: PRICE,
411 width,
412 points: std::mem::take(&mut current),
413 }));
414 }
415 current.push(point);
416 }
417 if current.len() >= 2 {
418 segments.push(Series::Line(LineSeries {
419 name: "mid-price".to_string(),
420 color: PRICE,
421 width,
422 points: current,
423 }));
424 }
425 segments
426}
427
428fn aggregate_klines_for_timeframe(
429 klines: &[crate::dataset::types::DerivedKlineRow],
430 timeframe: MarketTimeframe,
431) -> Vec<crate::dataset::types::DerivedKlineRow> {
432 match timeframe {
433 MarketTimeframe::Tick1s => klines.to_vec(),
434 MarketTimeframe::Minute1m => aggregate_klines(klines, bucket_start_minute),
435 MarketTimeframe::Minute3m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 3)),
436 MarketTimeframe::Minute5m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 5)),
437 MarketTimeframe::Minute15m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 15)),
438 MarketTimeframe::Minute30m => aggregate_klines(klines, |ms| bucket_start_minutes(ms, 30)),
439 MarketTimeframe::Hour1h => aggregate_klines(klines, |ms| bucket_start_hours(ms, 1)),
440 MarketTimeframe::Hour4h => aggregate_klines(klines, |ms| bucket_start_hours(ms, 4)),
441 MarketTimeframe::Week1w => aggregate_klines(klines, bucket_start_week),
442 MarketTimeframe::Day1d => aggregate_klines(klines, bucket_start_day),
443 MarketTimeframe::Month1mo => aggregate_klines(klines, bucket_start_month),
444 }
445}
446
447fn aggregate_klines(
448 klines: &[crate::dataset::types::DerivedKlineRow],
449 bucket_start: fn(i64) -> i64,
450) -> Vec<crate::dataset::types::DerivedKlineRow> {
451 if klines.is_empty() {
452 return Vec::new();
453 }
454 let mut aggregated = Vec::new();
455 let mut current_bucket = bucket_start(klines[0].open_time_ms);
456 let mut current = klines[0].clone();
457 current.open_time_ms = current_bucket;
458
459 for row in klines.iter().skip(1) {
460 let next_bucket = bucket_start(row.open_time_ms);
461 if next_bucket != current_bucket {
462 aggregated.push(current);
463 current_bucket = next_bucket;
464 current = row.clone();
465 current.open_time_ms = current_bucket;
466 continue;
467 }
468 current.close_time_ms = row.close_time_ms;
469 current.high = current.high.max(row.high);
470 current.low = current.low.min(row.low);
471 current.close = row.close;
472 current.volume += row.volume;
473 current.quote_volume += row.quote_volume;
474 current.trade_count += row.trade_count;
475 }
476 aggregated.push(current);
477 aggregated
478}
479
480fn bucket_start_minute(ms: i64) -> i64 {
481 (ms / 60_000) * 60_000
482}
483
484fn bucket_start_minutes(ms: i64, minutes: i64) -> i64 {
485 let bucket_ms = minutes * 60_000;
486 (ms / bucket_ms) * bucket_ms
487}
488
489fn bucket_start_hours(ms: i64, hours: i64) -> i64 {
490 let bucket_ms = hours * 60 * 60_000;
491 (ms / bucket_ms) * bucket_ms
492}
493
494fn bucket_start_day(ms: i64) -> i64 {
495 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
496 .and_then(|dt| {
497 dt.date_naive()
498 .and_hms_opt(0, 0, 0)
499 .map(|naive| naive.and_utc().timestamp_millis())
500 })
501 .unwrap_or(ms)
502}
503
504fn bucket_start_week(ms: i64) -> i64 {
505 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
506 .and_then(|dt| {
507 let date = dt.date_naive();
508 let weekday_offset = i64::from(date.weekday().num_days_from_monday());
509 date.checked_sub_days(chrono::Days::new(weekday_offset as u64))
510 .and_then(|start| start.and_hms_opt(0, 0, 0))
511 .map(|naive| naive.and_utc().timestamp_millis())
512 })
513 .unwrap_or(ms)
514}
515
516fn bucket_start_month(ms: i64) -> i64 {
517 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
518 .and_then(|dt| {
519 chrono::NaiveDate::from_ymd_opt(dt.year(), dt.month(), 1)
520 .and_then(|date| date.and_hms_opt(0, 0, 0))
521 .map(|naive| naive.and_utc().timestamp_millis())
522 })
523 .unwrap_or(ms)
524}
525
526impl From<TooltipModel> for HoverModel {
527 fn from(tooltip: TooltipModel) -> Self {
528 Self {
529 crosshair: None,
530 tooltip: Some(tooltip),
531 }
532 }
533}