Skip to main content

sandbox_quant/charting/adapters/
sandbox.rs

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}