feat(lapaloma-financial): OHLC + candlesticks con preserva-volatilidad

- axis.rs: paint_axes extraído a función pública reusable entre
  crates de visualización. LapalomaChartElement::paint_axes ahora
  es un thin wrapper.
- OhlcBuffer: stride 6 f32 por bar (t, o, h, l, c, v). Bar struct
  con is_bull/is_bear. price_range y time_range. 5 tests.
- aggregate_time_bucketed (sección 3.2 del ARCHITECTURE.md):
  buckets por TIEMPO (no índice) — open=first, close=last,
  high=max, low=min, volume=sum. Preserva volatilidad (los wicks
  sobreviven al downsample, a diferencia de LTTB). Fallback a
  copy 1:1 si el span temporal es cero. 4 tests cubren bucket
  count, preservation of volatility, fallback, empty input.
- paint_candlesticks: render agnóstico contra el trait Canvas.
  Wick = stroke_line vertical (high → low). Body = fill_rect
  open ↔ close con color bull/bear/neutral. body_width derivado
  del spacing entre bars (con body_min_width floor).
- LapalomaCandlestickElement: Element GPUI que reusa paint_axes
  + paint_candlesticks. Sin pan-blit cache en v0.1 (≤500 bars
  on-screen no lo necesita).
- crates/apps/lapaloma-financial-demo: random walk determinístico
  (xorshift32 inline + seed fijo) de 120 bars, pan + zoom + reset
  igual que el cartesian demo. Paleta nórdica para bull (#a3be8c)
  y bear (#bf616a).

60 tests verdes (28 cartesian + 20 core + 9 financial + 3 render).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-13 03:22:21 +00:00
parent 2b8e990cf9
commit d1ce4c8970
12 changed files with 1065 additions and 98 deletions
@@ -20,6 +20,10 @@
//! semántica.
use lapaloma_core::scale::nice_step;
use lapaloma_render::{Canvas, Color, Point, StrokeStyle};
use crate::coord_system::CoordinateSystem;
use crate::viewport::ChartViewport;
/// Lado del plot donde vive el eje.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -135,6 +139,111 @@ impl Default for AxisStyle {
}
}
const MONO_GLYPH_RATIO: f32 = 0.55;
/// Pinta las dos líneas base (X y Y), los tick marks y los labels
/// decimados de ambos ejes. Función reusable entre crates de
/// visualización (cartesian, financial, etc.) — recibe todo por
/// args para no atarse al state de un Element específico.
pub fn paint_axes(
canvas: &mut dyn Canvas,
cs: &CoordinateSystem,
viewport: &ChartViewport,
color: Color,
style: AxisStyle,
target_ticks_x: usize,
target_ticks_y: usize,
) {
let plot = cs.plot;
let axis_stroke = StrokeStyle::new(style.axis_line_width_px, color);
let tick_stroke = StrokeStyle::new(style.tick_width_px, color);
let tlen = style.tick_length_px;
canvas.stroke_line(
Point::new(plot.x, plot.bottom()),
Point::new(plot.right(), plot.bottom()),
axis_stroke,
);
canvas.stroke_line(
Point::new(plot.x, plot.y),
Point::new(plot.x, plot.bottom()),
axis_stroke,
);
// X axis ticks + labels.
let x_ticks = ticks_nice(viewport.x_min, viewport.x_max, target_ticks_x);
let x_step = nice_step(viewport.x_min, viewport.x_max, target_ticks_x);
let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
for v in &x_ticks {
let pixel = cs.data_to_pixel(*v, viewport.y_min).x;
if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
continue;
}
canvas.stroke_line(
Point::new(pixel, plot.bottom()),
Point::new(pixel, plot.bottom() + tlen),
tick_stroke,
);
let lbl = format_tick(*v, x_step);
let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
x_pos.push(pixel);
x_widths.push(w);
x_lbl.push(lbl);
}
let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
for i in keep_x {
let half = x_widths[i] * 0.5;
canvas.draw_text(
Point::new(
x_pos[i] - half,
plot.bottom() + tlen + style.label_offset_px,
),
&x_lbl[i],
color,
style.label_size_px,
);
}
// Y axis ticks + labels con decimación vertical.
let y_ticks = ticks_nice(viewport.y_min, viewport.y_max, target_ticks_y);
let y_step = nice_step(viewport.y_min, viewport.y_max, target_ticks_y);
let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
let mut prev_py: Option<f32> = None;
for v in &y_ticks {
let py = cs.data_to_pixel(viewport.x_min, *v).y;
if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
continue;
}
canvas.stroke_line(
Point::new(plot.x - tlen, py),
Point::new(plot.x, py),
tick_stroke,
);
let label_ok = match prev_py {
None => true,
Some(p) => (py - p).abs() >= y_label_pitch,
};
if !label_ok {
continue;
}
let lbl = format_tick(*v, y_step);
let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
canvas.draw_text(
Point::new(
plot.x - tlen - style.label_offset_px - w,
py - style.label_size_px * 0.5,
),
&lbl,
color,
style.label_size_px,
);
prev_py = Some(py);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -32,17 +32,15 @@ use gpui::{
};
use lapaloma_core::buffer::DataBuffer;
use lapaloma_core::scale::nice_step;
use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
use lapaloma_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas};
use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle};
use crate::axis::{self, AxisStyle};
use crate::coord_system::CoordinateSystem;
use crate::series::LineSeries;
use crate::viewport::ChartViewport;
const TARGET_TICKS_X: usize = 8;
const TARGET_TICKS_Y: usize = 6;
const MONO_GLYPH_RATIO: f32 = 0.55;
/// Cache de coords proyectadas para reuso entre frames. Es lo
/// que habilita el pan-blit: el caller lo crea una vez y lo
@@ -188,91 +186,15 @@ impl LapalomaChartElement {
}
fn paint_axes(&self, canvas: &mut dyn Canvas, cs: &CoordinateSystem) {
let plot = cs.plot;
let style = self.axis_style;
let axis_stroke = StrokeStyle::new(style.axis_line_width_px, self.axis_color);
let tick_stroke = StrokeStyle::new(style.tick_width_px, self.axis_color);
let tlen = style.tick_length_px;
canvas.stroke_line(
Point::new(plot.x, plot.bottom()),
Point::new(plot.right(), plot.bottom()),
axis_stroke,
axis::paint_axes(
canvas,
cs,
&self.viewport,
self.axis_color,
self.axis_style,
TARGET_TICKS_X,
TARGET_TICKS_Y,
);
canvas.stroke_line(
Point::new(plot.x, plot.y),
Point::new(plot.x, plot.bottom()),
axis_stroke,
);
let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
let mut x_lbl: Vec<String> = Vec::with_capacity(x_ticks.len());
let mut x_widths: Vec<f32> = Vec::with_capacity(x_ticks.len());
for v in &x_ticks {
let pixel = cs.data_to_pixel(*v, self.viewport.y_min).x;
if pixel < plot.x - 0.5 || pixel > plot.right() + 0.5 {
continue;
}
canvas.stroke_line(
Point::new(pixel, plot.bottom()),
Point::new(pixel, plot.bottom() + tlen),
tick_stroke,
);
let lbl = format_tick(*v, x_step);
let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
x_pos.push(pixel);
x_widths.push(w);
x_lbl.push(lbl);
}
let keep_x = decimate_labels(&x_pos, &x_widths, style.label_min_spacing_px);
for i in keep_x {
let half = x_widths[i] * 0.5;
let origin_x = x_pos[i] - half;
let origin_y = plot.bottom() + tlen + style.label_offset_px;
canvas.draw_text(
Point::new(origin_x, origin_y),
&x_lbl[i],
self.axis_color,
style.label_size_px,
);
}
let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
let mut prev_py: Option<f32> = None;
for v in &y_ticks {
let py = cs.data_to_pixel(self.viewport.x_min, *v).y;
if py < plot.y - 0.5 || py > plot.bottom() + 0.5 {
continue;
}
canvas.stroke_line(
Point::new(plot.x - tlen, py),
Point::new(plot.x, py),
tick_stroke,
);
let label_ok = match prev_py {
None => true,
Some(p) => (py - p).abs() >= y_label_pitch,
};
if !label_ok {
continue;
}
let lbl = format_tick(*v, y_step);
let w = lbl.len() as f32 * style.label_size_px * MONO_GLYPH_RATIO;
let origin_x = plot.x - tlen - style.label_offset_px - w;
let origin_y = py - style.label_size_px * 0.5;
canvas.draw_text(
Point::new(origin_x, origin_y),
&lbl,
self.axis_color,
style.label_size_px,
);
prev_py = Some(py);
}
}
/// Rebuild full: LTTB + projection por serie. Pinta directo desde