tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

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>

d1ce4c89706fc7c9456d7b28e4b9f98d85a5921a · by sergio · (5 months ago) · parent 2b8e990cf · browse the tree at this commit

Semantic delta · 73

crates/apps/lapaloma-financial-demo/src/main.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs

crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs

crates/modules/ui_engine/widgets/lapaloma-financial/src/aggregate.rs

crates/modules/ui_engine/widgets/lapaloma-financial/src/candlestick.rs

crates/modules/ui_engine/widgets/lapaloma-financial/src/element.rs

crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs

crates/modules/ui_engine/widgets/lapaloma-financial/src/ohlc_buffer.rs

12 files · +1065 −98

diff --git a/Cargo.lock b/Cargo.lock
index 99ca330..2b6d924 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5423,6 +5423,18 @@ dependencies = [
  "lapaloma-render",
 ]
 
+[[package]]
+name = "lapaloma-financial-demo"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-cartesian",
+ "lapaloma-financial",
+ "lapaloma-render",
+ "yahweh-launcher",
+ "yahweh-theme",
+]
+
 [[package]]
 name = "lapaloma-flow"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 74acc4b..b5165d8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -139,6 +139,7 @@ members = [
     "crates/apps/lapaloma-demo",
     "crates/apps/lapaloma-stream-demo",
     "crates/apps/lapaloma-phosphor-demo",
+    "crates/apps/lapaloma-financial-demo",
 ]
 
 [workspace.package]
diff --git a/crates/apps/lapaloma-financial-demo/Cargo.toml b/crates/apps/lapaloma-financial-demo/Cargo.toml
new file mode 100644
index 0000000..b9247da
--- /dev/null
+++ b/crates/apps/lapaloma-financial-demo/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "lapaloma-financial-demo"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — demo de candlesticks OHLC. Random walk sintético de 120 días con pan + zoom."
+
+[dependencies]
+gpui = { workspace = true }
+yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" }
+yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
+lapaloma-render = { path = "../../modules/ui_engine/widgets/lapaloma-render", features = ["gpui"] }
+lapaloma-cartesian = { path = "../../modules/ui_engine/widgets/lapaloma-cartesian" }
+lapaloma-financial = { path = "../../modules/ui_engine/widgets/lapaloma-financial" }
diff --git a/crates/apps/lapaloma-financial-demo/src/main.rs b/crates/apps/lapaloma-financial-demo/src/main.rs
new file mode 100644
index 0000000..ae59f5d
--- /dev/null
+++ b/crates/apps/lapaloma-financial-demo/src/main.rs
@@ -0,0 +1,231 @@
+//! `lapaloma-financial-demo` — chart OHLC con random walk.
+//!
+//! Genera 120 "días" de bars con un random walk determinístico
+//! (sin RNG runtime — derivado de un seed fijo + xorshift32 inline)
+//! y los pinta con `LapalomaCandlestickElement`. Pan + zoom igual
+//! al cartesian demo.
+
+use gpui::{
+    div, prelude::*, px, ClickEvent, Context, IntoElement, MouseButton, MouseDownEvent,
+    MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window,
+};
+
+use lapaloma_cartesian::ChartViewport;
+use lapaloma_financial::{
+    lapaloma_candlestick, Bar, CandlestickStyle, OhlcBuffer,
+};
+use lapaloma_render::Color;
+use yahweh_launcher::launch_app;
+use yahweh_theme::Theme;
+
+const N_BARS: usize = 120;
+const WHEEL_SENSITIVITY: f64 = 0.0015;
+
+fn main() {
+    launch_app(
+        "Lapaloma — candlesticks (drag = pan, wheel = zoom, dbl-click = reset)",
+        (960., 560.),
+        FinancialDemo::new,
+    );
+}
+
+struct FinancialDemo {
+    data: OhlcBuffer,
+    viewport: ChartViewport,
+    initial_viewport: ChartViewport,
+    drag: Option<DragAnchor>,
+}
+
+#[derive(Clone, Copy)]
+struct DragAnchor {
+    start_position: Point<gpui::Pixels>,
+    viewport_at_start: ChartViewport,
+}
+
+impl FinancialDemo {
+    fn new(_cx: &mut Context<Self>) -> Self {
+        let data = synth_random_walk(N_BARS, 100.0, 0xc0ffee);
+        let (lo, hi) = data.price_range().unwrap_or((0.0, 1.0));
+        let pad = (hi - lo) * 0.08;
+        let viewport = ChartViewport::new(
+            -0.5,
+            N_BARS as f64 - 0.5,
+            (lo - pad) as f64,
+            (hi + pad) as f64,
+        );
+        Self {
+            data,
+            viewport,
+            initial_viewport: viewport,
+            drag: None,
+        }
+    }
+
+    fn on_mouse_down(&mut self, e: &MouseDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
+        self.drag = Some(DragAnchor {
+            start_position: e.position,
+            viewport_at_start: self.viewport,
+        });
+        cx.notify();
+    }
+    fn on_mouse_move(&mut self, e: &MouseMoveEvent, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(anchor) = self.drag else { return };
+        let win = window.viewport_size();
+        let w: f32 = win.width.into();
+        let h: f32 = win.height.into();
+        if w <= 0.0 || h <= 0.0 {
+            return;
+        }
+        let sx: f32 = e.position.x.into();
+        let sy: f32 = e.position.y.into();
+        let ax: f32 = anchor.start_position.x.into();
+        let ay: f32 = anchor.start_position.y.into();
+        let dfx = ((sx - ax) / w) as f64;
+        let dfy = ((sy - ay) / h) as f64;
+        let mut vp = anchor.viewport_at_start;
+        vp.pan_fraction(dfx, dfy);
+        self.viewport = vp;
+        cx.notify();
+    }
+    fn on_mouse_up(&mut self, _e: &MouseUpEvent, _w: &mut Window, cx: &mut Context<Self>) {
+        if self.drag.take().is_some() {
+            cx.notify();
+        }
+    }
+    fn on_scroll(&mut self, e: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
+        let win = window.viewport_size();
+        let w: f32 = win.width.into();
+        let h: f32 = win.height.into();
+        if w <= 0.0 || h <= 0.0 {
+            return;
+        }
+        let dy_px: f32 = match e.delta {
+            ScrollDelta::Pixels(p) => p.y.into(),
+            ScrollDelta::Lines(p) => p.y * 16.0,
+        };
+        let factor = (-dy_px as f64 * WHEEL_SENSITIVITY).exp();
+        let sx: f32 = e.position.x.into();
+        let sy: f32 = e.position.y.into();
+        let ax = (sx / w).clamp(0.0, 1.0) as f64;
+        let ay = (1.0 - sy / h).clamp(0.0, 1.0) as f64;
+        self.viewport.zoom_uniform(factor, (ax, ay));
+        cx.notify();
+    }
+    fn on_click(&mut self, e: &ClickEvent, _w: &mut Window, cx: &mut Context<Self>) {
+        if let ClickEvent::Mouse(m) = e {
+            if m.up.click_count >= 2 {
+                self.viewport = self.initial_viewport;
+                cx.notify();
+            }
+        }
+    }
+}
+
+impl Render for FinancialDemo {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+        let plot_bg = Color::rgba(0.06, 0.08, 0.10, 1.0);
+
+        let style = CandlestickStyle {
+            bull_color: Color::from_hex(0xa3be8c),
+            bear_color: Color::from_hex(0xbf616a),
+            ..CandlestickStyle::default()
+        };
+
+        let chart = lapaloma_candlestick(self.data.clone(), self.viewport)
+            .background(plot_bg)
+            .style(style);
+
+        let (lo, hi) = self.data.price_range().unwrap_or((0.0, 0.0));
+        let drag_active = self.drag.is_some();
+
+        div()
+            .id("lapaloma-financial-root")
+            .size_full()
+            .bg(theme.bg_app.clone())
+            .p(px(16.))
+            .flex()
+            .flex_col()
+            .gap(px(10.))
+            .child(
+                div()
+                    .text_color(theme.fg_text)
+                    .text_size(px(18.))
+                    .child("Lapaloma — candlesticks"),
+            )
+            .child(
+                div()
+                    .flex()
+                    .gap(px(16.))
+                    .text_size(px(11.))
+                    .text_color(theme.fg_muted)
+                    .child(format!("{} bars (random walk)", N_BARS))
+                    .child(format!("price [{:.2}, {:.2}]", lo, hi))
+                    .child(if drag_active { "· dragging" } else { "" }),
+            )
+            .child(
+                div()
+                    .id("lapaloma-financial-chart")
+                    .w_full()
+                    .flex_grow()
+                    .child(chart)
+                    .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
+                    .on_mouse_move(cx.listener(Self::on_mouse_move))
+                    .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
+                    .on_scroll_wheel(cx.listener(Self::on_scroll))
+                    .on_click(cx.listener(Self::on_click)),
+            )
+    }
+}
+
+/// xorshift32 inline — RNG determinístico mínimo. No criptográfico,
+/// pero perfecto para series sintéticas reproducibles.
+fn xorshift32(state: &mut u32) -> u32 {
+    let mut x = *state;
+    x ^= x << 13;
+    x ^= x >> 17;
+    x ^= x << 5;
+    *state = x;
+    x
+}
+
+fn rand_f32(state: &mut u32) -> f32 {
+    xorshift32(state) as f32 / u32::MAX as f32
+}
+
+fn synth_random_walk(n: usize, start_price: f32, seed: u32) -> OhlcBuffer {
+    let mut rng = seed.max(1);
+    let mut buf = OhlcBuffer::with_capacity(n);
+    let mut close = start_price;
+    let drift = 0.05; // tendencia mínima alcista
+    let vol = 1.2;
+    for i in 0..n {
+        let r1 = rand_f32(&mut rng) - 0.5;
+        let r2 = rand_f32(&mut rng) - 0.5;
+        let r3 = rand_f32(&mut rng) - 0.5;
+        let r4 = rand_f32(&mut rng) - 0.5;
+
+        let open = close;
+        let move_close = drift + r1 * vol * 2.0;
+        let new_close = (open + move_close).max(1.0);
+        // Wicks: ruido por encima/debajo del rango open-close.
+        let body_hi = open.max(new_close);
+        let body_lo = open.min(new_close);
+        let wick_up = (r2.abs() * vol * 1.2).max(0.05);
+        let wick_dn = (r3.abs() * vol * 1.2).max(0.05);
+        let high = body_hi + wick_up;
+        let low = (body_lo - wick_dn).max(0.1);
+        let volume = 1000.0 + r4.abs() * 8000.0;
+
+        buf.push_bar(Bar {
+            t: i as f32,
+            o: open,
+            h: high,
+            l: low,
+            c: new_close,
+            v: volume,
+        });
+        close = new_close;
+    }
+    buf
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs
index 13a53a5..482d4d1 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/axis.rs
@@ -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::*;
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
index 6a0a299..233a5a9 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
@@ -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
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml
index 72029e0..1a7b2c6 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml
@@ -11,4 +11,8 @@ description = "Lapaloma — gráficos financieros. OHLC / candlesticks con agreg
 lapaloma-core = { path = "../../libs/lapaloma-core" }
 lapaloma-render = { path = "../lapaloma-render" }
 lapaloma-cartesian = { path = "../lapaloma-cartesian" }
-gpui = { workspace = true }
+gpui = { workspace = true, optional = true }
+
+[features]
+default = ["gpui"]
+gpui = ["dep:gpui", "lapaloma-render/gpui", "lapaloma-cartesian/gpui"]
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/src/aggregate.rs b/crates/modules/ui_engine/widgets/lapaloma-financial/src/aggregate.rs
new file mode 100644
index 0000000..cfe117a
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/src/aggregate.rs
@@ -0,0 +1,165 @@
+//! Aggregation de OHLC por bucket de **tiempo** (no de índice).
+//!
+//! Bucket index = `floor((bar.t - t_start) / bucket_duration)`.
+//! Cuando cambia el bucket, commit del anterior:
+//!
+//! - `open` = primer `open` del bucket.
+//! - `close` = último `close` del bucket.
+//! - `high` = max(`high`) del bucket.
+//! - `low` = min(`low`) del bucket.
+//! - `volume` = sum(`volume`) del bucket.
+//! - `t` = timestamp del primer bar del bucket (canónico para
+//!   ploteo; el doc original sugiere usar el inicio del bucket
+//!   pero acá preferimos el sample real para no introducir bias).
+//!
+//! Buckets vacíos no se emiten — el length de salida es ≤ inputs.
+//! Fallback a index-bucketing si el span temporal es cero (todos
+//! los timestamps colapsados, e.g. tick-data).
+
+use crate::ohlc_buffer::{Bar, OhlcBuffer, STRIDE};
+
+/// Agrega `src` en buckets de duración `bucket_duration` (en
+/// unidades de `bar.t`). Escribe el output en `out` extendiéndolo
+/// (no se clearea; el caller decide).
+///
+/// Si `bucket_duration <= 0` o el span del input es cero, hace
+/// fallback a index-bucketing con `samples_per_bucket = 1` (es decir,
+/// copia el input tal cual). Esto evita panic con tick-data
+/// colapsado.
+pub fn aggregate_time_bucketed(src: &OhlcBuffer, bucket_duration: f32, out: &mut OhlcBuffer) {
+    if src.is_empty() {
+        return;
+    }
+    let n = src.len();
+    let (t_first, t_last) = src.time_range().unwrap();
+
+    if bucket_duration <= 0.0 || (t_last - t_first).abs() < f32::EPSILON {
+        // Fallback: copia tal cual.
+        for i in 0..n {
+            out.push_bar(src.bar(i));
+        }
+        return;
+    }
+
+    let mut current_bucket = i64::MIN;
+    let mut acc_t: f32 = 0.0;
+    let mut acc_o: f32 = 0.0;
+    let mut acc_h: f32 = f32::NEG_INFINITY;
+    let mut acc_l: f32 = f32::INFINITY;
+    let mut acc_c: f32 = 0.0;
+    let mut acc_v: f32 = 0.0;
+    let mut has_acc = false;
+
+    for i in 0..n {
+        let b = src.bar(i);
+        let bucket = ((b.t - t_first) / bucket_duration).floor() as i64;
+        if bucket != current_bucket {
+            if has_acc {
+                out.push_bar(Bar {
+                    t: acc_t,
+                    o: acc_o,
+                    h: acc_h,
+                    l: acc_l,
+                    c: acc_c,
+                    v: acc_v,
+                });
+            }
+            current_bucket = bucket;
+            acc_t = b.t;
+            acc_o = b.o;
+            acc_h = b.h;
+            acc_l = b.l;
+            acc_c = b.c;
+            acc_v = b.v;
+            has_acc = true;
+        } else {
+            if b.h > acc_h {
+                acc_h = b.h;
+            }
+            if b.l < acc_l {
+                acc_l = b.l;
+            }
+            acc_c = b.c;
+            acc_v += b.v;
+        }
+    }
+    if has_acc {
+        out.push_bar(Bar {
+            t: acc_t,
+            o: acc_o,
+            h: acc_h,
+            l: acc_l,
+            c: acc_c,
+            v: acc_v,
+        });
+    }
+
+    // Una métrica de cordura: el output nunca puede ser más largo
+    // que el input.
+    debug_assert!(out.bars().len() / STRIDE <= n);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn fixture() -> OhlcBuffer {
+        // 10 bars con t en `[0, 9]`, valores deterministicos.
+        let mut b = OhlcBuffer::with_capacity(10);
+        for i in 0..10 {
+            let t = i as f32;
+            let base = 100.0 + (i as f32) * 0.5;
+            b.push_values(t, base, base + 1.0, base - 1.0, base + 0.2, 10.0);
+        }
+        b
+    }
+
+    #[test]
+    fn bucket_de_3_agrega_a_4_bars() {
+        // 10 inputs con t en `[0, 9]`, bucket 3 → buckets 0-2, 3-5, 6-8, 9.
+        // = 4 buckets.
+        let src = fixture();
+        let mut out = OhlcBuffer::new();
+        aggregate_time_bucketed(&src, 3.0, &mut out);
+        assert_eq!(out.len(), 4);
+    }
+
+    #[test]
+    fn aggregation_preserva_volatilidad() {
+        // Inventamos un bucket donde un bar tiene spike alto y otro
+        // spike bajo. El aggregate debe capturar AMBOS extremos.
+        let mut src = OhlcBuffer::new();
+        src.push_values(0.0, 10.0, 12.0, 9.0, 11.0, 5.0);
+        src.push_values(0.5, 11.0, 20.0, 10.5, 11.5, 5.0); // spike up
+        src.push_values(0.8, 11.5, 12.0, 2.0, 11.0, 5.0); // spike down
+        let mut out = OhlcBuffer::new();
+        aggregate_time_bucketed(&src, 1.0, &mut out);
+        assert_eq!(out.len(), 1);
+        let agg = out.bar(0);
+        assert_eq!(agg.h, 20.0, "max H debe sobrevivir");
+        assert_eq!(agg.l, 2.0, "min L debe sobrevivir");
+        assert_eq!(agg.o, 10.0, "first open");
+        assert_eq!(agg.c, 11.0, "last close");
+        assert_eq!(agg.v, 15.0, "sum volumes");
+    }
+
+    #[test]
+    fn fallback_a_index_si_span_cero() {
+        // Todos los t iguales — fallback copia 1:1.
+        let mut src = OhlcBuffer::new();
+        src.push_values(7.0, 1.0, 2.0, 0.0, 1.5, 1.0);
+        src.push_values(7.0, 1.5, 2.5, 1.0, 2.0, 1.0);
+        src.push_values(7.0, 2.0, 3.0, 1.0, 1.0, 1.0);
+        let mut out = OhlcBuffer::new();
+        aggregate_time_bucketed(&src, 1.0, &mut out);
+        assert_eq!(out.len(), 3, "span 0 ⇒ copy 1:1");
+    }
+
+    #[test]
+    fn empty_no_emite() {
+        let src = OhlcBuffer::new();
+        let mut out = OhlcBuffer::new();
+        aggregate_time_bucketed(&src, 1.0, &mut out);
+        assert_eq!(out.len(), 0);
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/src/candlestick.rs b/crates/modules/ui_engine/widgets/lapaloma-financial/src/candlestick.rs
new file mode 100644
index 0000000..a5ab6c6
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/src/candlestick.rs
@@ -0,0 +1,121 @@
+//! Render de candlesticks sobre cualquier `Canvas`.
+//!
+//! Por cada bar visible:
+//! - **Wick** = línea vertical de `(t, low)` a `(t, high)`.
+//! - **Body** = rect de `(t - body_w/2, open)` a `(t + body_w/2, close)`,
+//!   relleno bull (close > open) / bear (close < open) / neutro.
+//!
+//! Esta función es agnóstica de gpui — habla contra el trait
+//! `Canvas`. El `Element` GPUI que la consume vive en `element.rs`.
+
+use lapaloma_cartesian::CoordinateSystem;
+use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle};
+
+use crate::ohlc_buffer::OhlcBuffer;
+
+/// Estilo visual de los candlesticks.
+#[derive(Debug, Clone, Copy)]
+pub struct CandlestickStyle {
+    pub bull_color: Color,
+    pub bear_color: Color,
+    /// Color del body cuando open == close. Suele ser el axis color.
+    pub neutral_color: Color,
+    /// Ancho del wick (línea central). En píxeles.
+    pub wick_width: f32,
+    /// Ancho mínimo del body, en píxeles. Cuando el spacing entre
+    /// bars cae por debajo, el body usa este floor.
+    pub body_min_width: f32,
+    /// Fracción del spacing entre bars consecutivas que ocupa el body.
+    /// 0.7 deja un gap del 30% entre velas.
+    pub body_width_ratio: f32,
+}
+
+impl Default for CandlestickStyle {
+    fn default() -> Self {
+        Self {
+            bull_color: Color::from_hex(0x88c08a),
+            bear_color: Color::from_hex(0xbf616a),
+            neutral_color: Color::rgba(0.7, 0.7, 0.75, 1.0),
+            wick_width: 1.0,
+            body_min_width: 2.0,
+            body_width_ratio: 0.7,
+        }
+    }
+}
+
+/// Dibuja todas las velas del buffer visibles en el viewport del
+/// `CoordinateSystem`. Bars fuera de rango se skippean.
+pub fn paint_candlesticks(
+    canvas: &mut dyn Canvas,
+    cs: &CoordinateSystem,
+    data: &OhlcBuffer,
+    style: CandlestickStyle,
+) {
+    let n = data.len();
+    if n == 0 {
+        return;
+    }
+
+    let plot = cs.plot;
+    let viewport = cs.viewport;
+
+    // Spacing entre bars consecutivas en píxeles. Asume bars
+    // aproximadamente equiespaciadas en X (caso típico OHLC
+    // post-aggregation).
+    let body_width = if n >= 2 {
+        let first_t = data.bar(0).t as f64;
+        let last_t = data.bar(n - 1).t as f64;
+        let span_t = (last_t - first_t).max(f32::EPSILON as f64);
+        let span_px = (span_t / viewport.x_span()) * plot.w as f64;
+        let spacing = span_px / (n as f64 - 1.0);
+        ((spacing * style.body_width_ratio as f64) as f32).max(style.body_min_width)
+    } else {
+        style.body_min_width
+    };
+    let half_body = body_width * 0.5;
+
+    for i in 0..n {
+        let bar = data.bar(i);
+
+        // Clip aproximado: si el bar entero queda fuera del viewport
+        // X, lo saltamos.
+        if (bar.t as f64) < viewport.x_min - viewport.x_span() * 0.05
+            || (bar.t as f64) > viewport.x_max + viewport.x_span() * 0.05
+        {
+            continue;
+        }
+
+        let px_center = cs.data_to_pixel(bar.t as f64, bar.o as f64).x;
+        let py_open = cs.data_to_pixel(bar.t as f64, bar.o as f64).y;
+        let py_close = cs.data_to_pixel(bar.t as f64, bar.c as f64).y;
+        let py_high = cs.data_to_pixel(bar.t as f64, bar.h as f64).y;
+        let py_low = cs.data_to_pixel(bar.t as f64, bar.l as f64).y;
+
+        let color = if bar.is_bull() {
+            style.bull_color
+        } else if bar.is_bear() {
+            style.bear_color
+        } else {
+            style.neutral_color
+        };
+
+        // Wick: línea vertical de high a low.
+        canvas.stroke_line(
+            Point::new(px_center, py_high),
+            Point::new(px_center, py_low),
+            StrokeStyle::new(style.wick_width, color),
+        );
+
+        // Body: rect entre open y close.
+        let (y_top, y_bot) = if py_open < py_close {
+            (py_open, py_close)
+        } else {
+            (py_close, py_open)
+        };
+        let body_h = (y_bot - y_top).max(1.0); // floor 1px para doji
+        canvas.fill_rect(
+            Rect::new(px_center - half_body, y_top, body_width, body_h),
+            color,
+        );
+    }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-financial/src/element.rs
new file mode 100644
index 0000000..0a3d9fa
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/src/element.rs
@@ -0,0 +1,176 @@
+//! `LapalomaCandlestickElement` — Element GPUI para charts OHLC.
+//!
+//! Reusa `lapaloma_cartesian::axis::paint_axes` para los ejes y el
+//! `WindowCanvas` adapter de `lapaloma-render` para el output.
+//! El render mismo de las velas lo hace `paint_candlesticks` que
+//! es agnóstico de gpui — facilita futuros backends (SVG, wgpu).
+//!
+//! Sin cache pan-blit en v0.1: las velas se redibujan cada frame.
+//! Para hasta ~500 bars on-screen son sub-millisecond; con
+//! aggregation razonable (1 bar por columna de pixel) eso cubre
+//! cualquier caso humano. Si se necesita más, se replica el
+//! patrón de `LapalomaChartElement::with_cache`.
+
+use std::panic;
+
+use gpui::{
+    App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
+    Pixels, Style, Window,
+};
+
+use lapaloma_cartesian::axis::{self, AxisStyle};
+use lapaloma_cartesian::{ChartViewport, CoordinateSystem};
+use lapaloma_render::{Canvas, Color, Rect, WindowCanvas};
+
+use crate::candlestick::{paint_candlesticks, CandlestickStyle};
+use crate::ohlc_buffer::OhlcBuffer;
+
+const TARGET_TICKS_X: usize = 8;
+const TARGET_TICKS_Y: usize = 6;
+
+pub struct LapalomaCandlestickElement {
+    pub data: OhlcBuffer,
+    pub viewport: ChartViewport,
+    pub style: CandlestickStyle,
+    pub background: Option<Color>,
+    pub axis_color: Color,
+    pub axis_style: AxisStyle,
+    pub margin_bottom: f32,
+    pub margin_left: f32,
+    pub margin_top: f32,
+    pub margin_right: f32,
+}
+
+impl LapalomaCandlestickElement {
+    pub fn new(data: OhlcBuffer, viewport: ChartViewport) -> Self {
+        Self {
+            data,
+            viewport,
+            style: CandlestickStyle::default(),
+            background: None,
+            axis_color: Color::rgba(0.6, 0.6, 0.65, 0.8),
+            axis_style: AxisStyle::default(),
+            margin_bottom: 24.0,
+            margin_left: 48.0,
+            margin_top: 8.0,
+            margin_right: 8.0,
+        }
+    }
+
+    pub fn background(mut self, color: Color) -> Self {
+        self.background = Some(color);
+        self
+    }
+    pub fn axis_color(mut self, color: Color) -> Self {
+        self.axis_color = color;
+        self
+    }
+    pub fn style(mut self, style: CandlestickStyle) -> Self {
+        self.style = style;
+        self
+    }
+    pub fn margins(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
+        self.margin_top = top;
+        self.margin_right = right;
+        self.margin_bottom = bottom;
+        self.margin_left = left;
+        self
+    }
+
+    fn plot_rect(&self, bounds: Rect) -> Rect {
+        Rect::new(
+            bounds.x + self.margin_left,
+            bounds.y + self.margin_top,
+            (bounds.w - self.margin_left - self.margin_right).max(1.0),
+            (bounds.h - self.margin_top - self.margin_bottom).max(1.0),
+        )
+    }
+}
+
+impl IntoElement for LapalomaCandlestickElement {
+    type Element = Self;
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+impl Element for LapalomaCandlestickElement {
+    type RequestLayoutState = ();
+    type PrepaintState = ();
+
+    fn id(&self) -> Option<ElementId> {
+        None
+    }
+    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        let mut style = Style::default();
+        style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
+        style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
+        let id = window.request_layout(style, [], cx);
+        (id, ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        _bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> Self::PrepaintState {
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        _prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        _cx: &mut App,
+    ) {
+        let ox: f32 = bounds.origin.x.into();
+        let oy: f32 = bounds.origin.y.into();
+        let w: f32 = bounds.size.width.into();
+        let h: f32 = bounds.size.height.into();
+        let outer = Rect::new(ox, oy, w, h);
+        let plot = self.plot_rect(outer);
+
+        let cs = CoordinateSystem::new(self.viewport, plot);
+        let mut canvas = WindowCanvas::new(window);
+
+        if let Some(bg) = self.background {
+            canvas.fill_rect(outer, bg);
+        }
+
+        axis::paint_axes(
+            &mut canvas,
+            &cs,
+            &self.viewport,
+            self.axis_color,
+            self.axis_style,
+            TARGET_TICKS_X,
+            TARGET_TICKS_Y,
+        );
+
+        paint_candlesticks(&mut canvas, &cs, &self.data, self.style);
+    }
+}
+
+pub fn lapaloma_candlestick(
+    data: OhlcBuffer,
+    viewport: ChartViewport,
+) -> LapalomaCandlestickElement {
+    LapalomaCandlestickElement::new(data, viewport)
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs
index 2c1d7d1..1a292e5 100644
--- a/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs
@@ -1,16 +1,40 @@
 //! `lapaloma-financial` — OHLC y candlesticks.
 //!
-//! Buffer: 6 floats por bar `[t, o, h, l, c, v]`. Agregación
-//! preserva volatilidad (max(h)/min(l), no LTTB — ver sección 3.2):
-//! time-bucketing con fallback a index-bucketing cuando todos los
-//! timestamps colapsan.
+//! Layout del buffer: 6 floats por bar `[t, o, h, l, c, v]` (time,
+//! open, high, low, close, volume). Mismo principio P1 del doc
+//! canónico: array plano, sin objetos por bar.
 //!
-//! Re-usa `lapaloma-cartesian` para viewport, ejes y gestures;
-//! sólo aporta el `CandlestickSeries` y la lógica de aggregación.
+//! Aggregation (sección 3.2 del ARCHITECTURE.md):
+//! - **Time bucketing** (no index bucketing) para que weekends /
+//!   holidays no colapsen la rate.
+//! - `open` = primero del bucket, `close` = último, `high` = max,
+//!   `low` = min, `volume` = sum.
+//! - **Preserva volatilidad** — LTTB caería los wicks; estos los
+//!   conserva por construcción.
+//!
+//! Render: dos batches separados — barras alcistas (close > open,
+//! verdes) y bajistas (close < open, rojas). v0.1 emite un quad
+//! por body + un line por wick (≈ 2 draw calls por bar; aceptable
+//! hasta ~500 bars on-screen). Optimización futura: agrupar
+//! N bodies en un solo PathBuilder fill.
 
 #![forbid(unsafe_code)]
 #![allow(dead_code)]
 
-pub mod ohlc_buffer {}
-pub mod aggregate {}
-pub mod candlestick {}
+pub mod ohlc_buffer;
+pub mod aggregate;
+
+#[cfg(feature = "gpui")]
+pub mod candlestick;
+
+#[cfg(feature = "gpui")]
+pub mod element;
+
+pub use ohlc_buffer::{Bar, OhlcBuffer};
+pub use aggregate::aggregate_time_bucketed;
+
+#[cfg(feature = "gpui")]
+pub use candlestick::{paint_candlesticks, CandlestickStyle};
+
+#[cfg(feature = "gpui")]
+pub use element::{lapaloma_candlestick, LapalomaCandlestickElement};
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/src/ohlc_buffer.rs b/crates/modules/ui_engine/widgets/lapaloma-financial/src/ohlc_buffer.rs
new file mode 100644
index 0000000..8838450
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/src/ohlc_buffer.rs
@@ -0,0 +1,186 @@
+//! `OhlcBuffer` — buffer plano de bars con stride 6 `f32`.
+//!
+//! Memoria contigua: `[t0, o0, h0, l0, c0, v0, t1, o1, …]`.
+//! Acceso O(1) por índice; un memcpy completo para hidratar desde
+//! una fuente externa.
+
+/// Una barra OHLC + volumen. Valor leído del buffer; no es la
+/// representación de almacenamiento (que vive como `[f32; 6]`).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Bar {
+    pub t: f32,
+    pub o: f32,
+    pub h: f32,
+    pub l: f32,
+    pub c: f32,
+    pub v: f32,
+}
+
+impl Bar {
+    pub fn is_bull(self) -> bool {
+        self.c > self.o
+    }
+    pub fn is_bear(self) -> bool {
+        self.c < self.o
+    }
+}
+
+pub const STRIDE: usize = 6;
+
+#[derive(Debug, Clone, Default)]
+pub struct OhlcBuffer {
+    bars: Vec<f32>,
+    revision: u64,
+}
+
+impl OhlcBuffer {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn with_capacity(n: usize) -> Self {
+        Self {
+            bars: Vec::with_capacity(n * STRIDE),
+            revision: 0,
+        }
+    }
+
+    pub fn from_raw(bars: Vec<f32>) -> Self {
+        assert!(bars.len() % STRIDE == 0, "OhlcBuffer: stride 6 required");
+        Self { bars, revision: 0 }
+    }
+
+    pub fn push_bar(&mut self, b: Bar) {
+        self.bars.push(b.t);
+        self.bars.push(b.o);
+        self.bars.push(b.h);
+        self.bars.push(b.l);
+        self.bars.push(b.c);
+        self.bars.push(b.v);
+        self.revision = self.revision.wrapping_add(1);
+    }
+
+    pub fn push_values(&mut self, t: f32, o: f32, h: f32, l: f32, c: f32, v: f32) {
+        self.push_bar(Bar { t, o, h, l, c, v });
+    }
+
+    pub fn len(&self) -> usize {
+        self.bars.len() / STRIDE
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.bars.is_empty()
+    }
+
+    pub fn bar(&self, i: usize) -> Bar {
+        let off = i * STRIDE;
+        Bar {
+            t: self.bars[off],
+            o: self.bars[off + 1],
+            h: self.bars[off + 2],
+            l: self.bars[off + 3],
+            c: self.bars[off + 4],
+            v: self.bars[off + 5],
+        }
+    }
+
+    /// Slice plano del buffer subyacente.
+    pub fn bars(&self) -> &[f32] {
+        &self.bars
+    }
+
+    pub fn revision(&self) -> u64 {
+        self.revision
+    }
+
+    pub fn clear(&mut self) {
+        self.bars.clear();
+        self.revision = self.revision.wrapping_add(1);
+    }
+
+    /// Min/max de `low` y `high` sobre todo el buffer.
+    /// Útil para autoscale del Y axis.
+    pub fn price_range(&self) -> Option<(f32, f32)> {
+        if self.is_empty() {
+            return None;
+        }
+        let mut lo = f32::INFINITY;
+        let mut hi = f32::NEG_INFINITY;
+        for i in 0..self.len() {
+            let b = self.bar(i);
+            if b.l < lo {
+                lo = b.l;
+            }
+            if b.h > hi {
+                hi = b.h;
+            }
+        }
+        Some((lo, hi))
+    }
+
+    /// Rango temporal `[t_min, t_max]`. None si vacío.
+    pub fn time_range(&self) -> Option<(f32, f32)> {
+        if self.is_empty() {
+            return None;
+        }
+        let first = self.bars[0];
+        let last = self.bars[self.bars.len() - STRIDE];
+        Some((first, last))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn push_y_lectura() {
+        let mut b = OhlcBuffer::with_capacity(2);
+        b.push_values(1.0, 10.0, 12.0, 9.0, 11.0, 100.0);
+        b.push_values(2.0, 11.0, 13.0, 10.0, 10.5, 80.0);
+        assert_eq!(b.len(), 2);
+        assert_eq!(b.bar(0).c, 11.0);
+        assert_eq!(b.bar(1).h, 13.0);
+    }
+
+    #[test]
+    fn bull_y_bear() {
+        let bull = Bar { t: 0.0, o: 10.0, h: 11.0, l: 9.0, c: 10.5, v: 0.0 };
+        let bear = Bar { t: 0.0, o: 10.0, h: 11.0, l: 9.0, c: 9.5, v: 0.0 };
+        assert!(bull.is_bull());
+        assert!(!bull.is_bear());
+        assert!(bear.is_bear());
+        assert!(!bear.is_bull());
+    }
+
+    #[test]
+    fn price_range_correcto() {
+        let mut b = OhlcBuffer::new();
+        b.push_values(0.0, 10.0, 15.0, 8.0, 12.0, 0.0);
+        b.push_values(1.0, 12.0, 14.0, 7.0, 9.0, 0.0);
+        b.push_values(2.0, 9.0, 11.0, 9.0, 10.0, 0.0);
+        let (lo, hi) = b.price_range().unwrap();
+        assert_eq!(lo, 7.0);
+        assert_eq!(hi, 15.0);
+    }
+
+    #[test]
+    fn time_range() {
+        let mut b = OhlcBuffer::new();
+        b.push_values(10.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+        b.push_values(50.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+        b.push_values(100.0, 0.0, 0.0, 0.0, 0.0, 0.0);
+        assert_eq!(b.time_range(), Some((10.0, 100.0)));
+    }
+
+    #[test]
+    fn revision_bumps_en_push_y_clear() {
+        let mut b = OhlcBuffer::new();
+        let r0 = b.revision();
+        b.push_values(0.0, 1.0, 1.0, 1.0, 1.0, 1.0);
+        assert_ne!(r0, b.revision());
+        let r1 = b.revision();
+        b.clear();
+        assert_ne!(r1, b.revision());
+    }
+}