From d1ce4c89706fc7c9456d7b28e4b9f98d85a5921a Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 03:22:21 +0000 Subject: [PATCH] feat(lapaloma-financial): OHLC + candlesticks con preserva-volatilidad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 12 + Cargo.toml | 1 + .../apps/lapaloma-financial-demo/Cargo.toml | 16 ++ .../apps/lapaloma-financial-demo/src/main.rs | 231 ++++++++++++++++++ .../widgets/lapaloma-cartesian/src/axis.rs | 109 +++++++++ .../widgets/lapaloma-cartesian/src/element.rs | 98 +------- .../widgets/lapaloma-financial/Cargo.toml | 6 +- .../lapaloma-financial/src/aggregate.rs | 165 +++++++++++++ .../lapaloma-financial/src/candlestick.rs | 121 +++++++++ .../widgets/lapaloma-financial/src/element.rs | 176 +++++++++++++ .../widgets/lapaloma-financial/src/lib.rs | 42 +++- .../lapaloma-financial/src/ohlc_buffer.rs | 186 ++++++++++++++ 12 files changed, 1065 insertions(+), 98 deletions(-) create mode 100644 crates/apps/lapaloma-financial-demo/Cargo.toml create mode 100644 crates/apps/lapaloma-financial-demo/src/main.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-financial/src/aggregate.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-financial/src/candlestick.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-financial/src/element.rs create mode 100644 crates/modules/ui_engine/widgets/lapaloma-financial/src/ohlc_buffer.rs 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, +} + +#[derive(Clone, Copy)] +struct DragAnchor { + start_position: Point, + viewport_at_start: ChartViewport, +} + +impl FinancialDemo { + fn new(_cx: &mut Context) -> 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.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) { + 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) { + if self.drag.take().is_some() { + cx.notify(); + } + } + fn on_scroll(&mut self, e: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + 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) { + 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) -> 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 = Vec::with_capacity(x_ticks.len()); + let mut x_lbl: Vec = Vec::with_capacity(x_ticks.len()); + let mut x_widths: Vec = 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 = 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 = Vec::with_capacity(x_ticks.len()); - let mut x_lbl: Vec = Vec::with_capacity(x_ticks.len()); - let mut x_widths: Vec = 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 = 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, + 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 { + 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, + _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, + _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, + 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) -> 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()); + } +}