Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/cdcx-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,11 +842,12 @@ impl App {
.first() // Market tab (index 0)
.map(|tab| tab.get_candles(&inst))
.unwrap_or(&[]);
let filled = crate::widgets::candlestick::fill_candle_gaps(candles, 3_600_000);
crate::widgets::candlestick::draw_candlestick(
frame,
right,
&inst,
candles,
&filled,
"1h",
&self.state.theme.colors,
"\\:close split",
Expand Down
20 changes: 14 additions & 6 deletions crates/cdcx-tui/src/tabs/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use std::collections::HashMap;
use crate::format::{format_compact, format_price};
use crate::state::{AppState, RestRequest};
use crate::tabs::{DataEvent, Tab};
use crate::widgets::candlestick::{draw_candlestick, draw_compare_charts, Candle};
use crate::widgets::candlestick::{
draw_candlestick, draw_compare_charts, fill_candle_gaps, Candle,
};
use crate::widgets::detail_view::draw_detail;
use crate::widgets::instrument_picker::{InstrumentPicker, PickerResult};

Expand Down Expand Up @@ -756,16 +758,17 @@ impl Tab for MarketTab {
return;
}
ViewMode::Chart => {
let candles = self
let raw = self
.candles
.get(&self.detail_instrument)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let filled = fill_candle_gaps(raw, self.timeframe_ms());
draw_candlestick(
frame,
area,
&self.detail_instrument,
candles,
&filled,
&self.timeframe,
&state.theme.colors,
&format!(
Expand All @@ -776,14 +779,19 @@ impl Tab for MarketTab {
return;
}
ViewMode::Compare => {
let charts: Vec<(&str, &[Candle])> = self
let interval_ms = self.timeframe_ms();
let filled: Vec<(String, Vec<Candle>)> = self
.compare_instruments
.iter()
.map(|inst| {
let candles = self.candles.get(inst).map(|v| v.as_slice()).unwrap_or(&[]);
(inst.as_str(), candles)
let raw = self.candles.get(inst).map(|v| v.as_slice()).unwrap_or(&[]);
(inst.clone(), fill_candle_gaps(raw, interval_ms))
})
.collect();
let charts: Vec<(&str, &[Candle])> = filled
.iter()
.map(|(inst, candles)| (inst.as_str(), candles.as_slice()))
.collect();

let [chart_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Expand Down
105 changes: 105 additions & 0 deletions crates/cdcx-tui/src/widgets/candlestick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,36 @@ fn parse_f64(val: &serde_json::Value, key: &str) -> Option<f64> {
.and_then(|s| s.parse().ok())
}

/// Fill gaps in a candle series by inserting synthetic zero-volume flat candles for any
/// timeframe periods that the exchange omitted (illiquid instruments like RWA perps return
/// candles only for periods with trading activity). Synthetic candles carry forward the
/// previous close as O/H/L/C with `volume = 0`, so the renderer draws them as a flat line.
pub fn fill_candle_gaps(candles: &[Candle], interval_ms: u64) -> Vec<Candle> {
if interval_ms == 0 || candles.len() < 2 {
return candles.to_vec();
}
let mut out: Vec<Candle> = Vec::with_capacity(candles.len());
out.push(candles[0].clone());
for next in &candles[1..] {
let prev_close = out.last().map(|c| c.close).unwrap_or(0.0);
let prev_ts = out.last().map(|c| c.timestamp).unwrap_or(0);
let mut t = prev_ts.saturating_add(interval_ms);
while t < next.timestamp {
out.push(Candle {
open: prev_close,
high: prev_close,
low: prev_close,
close: prev_close,
volume: 0.0,
timestamp: t,
});
t = t.saturating_add(interval_ms);
}
out.push(next.clone());
}
out
}

/// Draw a single-instrument candlestick chart with header and footer.
pub fn draw_candlestick(
frame: &mut Frame,
Expand Down Expand Up @@ -283,6 +313,7 @@ pub fn render_chart_panel(frame: &mut Frame, area: Rect, candles: &[Candle], col
let mut lines: Vec<Line> = Vec::with_capacity(chart_height);

// Price chart rows
let row_step = price_range / (price_rows.max(2) - 1) as f64;
for row in 0..price_rows {
let price_at_row = max_price - (row as f64 / (price_rows.max(2) - 1) as f64) * price_range;
let mut spans: Vec<Span> = Vec::new();
Expand All @@ -293,6 +324,25 @@ pub fn render_chart_panel(frame: &mut Frame, area: Rect, candles: &[Candle], col
));

for candle in &visible {
// Synthetic no-trade candles (gap-fill) have zero volume and zero range —
// snap them to the nearest price row and draw a dim dash so illiquid
// periods render as a flat line instead of an empty column.
let is_synthetic =
candle.volume == 0.0 && candle.open == candle.close && candle.high == candle.low;
if is_synthetic {
if (price_at_row - candle.close).abs() <= row_step / 2.0 {
// Fill the full 3-char cell (no inter-cell space) so adjacent
// synthetic candles join into one continuous horizontal line.
spans.push(Span::styled(
"\u{2500}\u{2500}\u{2500}",
Style::default().fg(colors.muted),
));
} else {
spans.push(Span::raw(" "));
}
continue;
}

let is_bullish = candle.close >= candle.open;
let body_top = candle.open.max(candle.close);
let body_bot = candle.open.min(candle.close);
Expand Down Expand Up @@ -433,3 +483,58 @@ fn format_chart_price(price: f64) -> String {
format!("{:.4}", price)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn candle(ts: u64, close: f64) -> Candle {
Candle {
open: close,
high: close,
low: close,
close,
volume: 1.0,
timestamp: ts,
}
}

#[test]
fn fill_candle_gaps_inserts_flat_candles_for_missing_periods() {
// 1h interval; missing two periods between t=0 and t=3h
let interval_ms = 3_600_000u64;
let input = vec![candle(0, 100.0), candle(3 * interval_ms, 110.0)];
let out = fill_candle_gaps(&input, interval_ms);

// Expect 4 candles total: real@0, synthetic@1h, synthetic@2h, real@3h
assert_eq!(out.len(), 4, "two gaps must be filled");
assert_eq!(out[1].timestamp, interval_ms);
assert_eq!(out[2].timestamp, 2 * interval_ms);
// Synthetic carry-forward: O=H=L=C=prev close, v=0
for synthetic in &out[1..=2] {
assert_eq!(synthetic.open, 100.0);
assert_eq!(synthetic.close, 100.0);
assert_eq!(synthetic.high, 100.0);
assert_eq!(synthetic.low, 100.0);
assert_eq!(synthetic.volume, 0.0);
}
// Real candle preserved at correct index
assert_eq!(out[3].close, 110.0);
assert_eq!(out[3].volume, 1.0);
}

#[test]
fn fill_candle_gaps_noop_when_contiguous() {
let interval_ms = 60_000u64;
let input = vec![candle(0, 1.0), candle(interval_ms, 2.0)];
let out = fill_candle_gaps(&input, interval_ms);
assert_eq!(out.len(), 2);
}

#[test]
fn fill_candle_gaps_handles_empty_and_single() {
assert!(fill_candle_gaps(&[], 60_000).is_empty());
let one = vec![candle(0, 1.0)];
assert_eq!(fill_candle_gaps(&one, 60_000).len(), 1);
}
}