Files
brahman/crates/modules/ui_engine/widgets/lapaloma-financial/src/ohlc_buffer.rs
T
sergio d1ce4c8970 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>
2026-05-13 03:22:21 +00:00

187 lines
4.7 KiB
Rust

//! `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());
}
}