use optionstratlib::prelude::Decimal;
use optionstratlib::visualization::{GraphData, Series2D, TraceMode};
use crate::replay::EquityPoint;
pub(crate) const EQUITY_SERIES_NAME: &str = "equity";
pub(crate) const MAX_EQUITY_POINTS: usize = 512;
#[derive(Debug)]
pub(crate) struct EquityGeometry {
graph: GraphData,
running_peak_cents: i64,
peak_drawdown_cents: i64,
seed_cents: i64,
raw_len: usize,
}
impl EquityGeometry {
#[must_use]
pub(crate) fn build(equity: &[EquityPoint], seed_cents: i64) -> Self {
let mut running_peak_cents = seed_cents;
let mut peak_drawdown_cents = 0i64;
fold_peak(equity, &mut running_peak_cents, &mut peak_drawdown_cents);
Self {
graph: build_series(equity),
running_peak_cents,
peak_drawdown_cents,
seed_cents,
raw_len: equity.len(),
}
}
pub(crate) fn extend_forward(&mut self, equity: &[EquityPoint]) {
let old_len = self.raw_len;
let new_len = equity.len();
debug_assert!(
new_len >= old_len,
"extend_forward is forward-only; a backward move must rebuild",
);
if new_len <= old_len {
return; }
if let Some(tail) = equity.get(old_len..) {
fold_peak(
tail,
&mut self.running_peak_cents,
&mut self.peak_drawdown_cents,
);
}
let stride = stride_for(new_len);
if stride == stride_for(old_len)
&& let GraphData::Series(series) = &mut self.graph
{
append_series(series, equity, old_len, new_len, stride);
} else {
self.graph = build_series(equity);
}
self.raw_len = new_len;
}
pub(crate) fn rebuild(&mut self, equity: &[EquityPoint]) {
*self = Self::build(equity, self.seed_cents);
}
#[must_use]
pub(crate) fn graph(&self) -> &GraphData {
&self.graph
}
#[must_use]
pub(crate) fn peak_drawdown_cents(&self) -> i64 {
self.peak_drawdown_cents
}
#[must_use]
pub(crate) fn raw_len(&self) -> usize {
self.raw_len
}
}
fn stride_for(n: usize) -> usize {
n.div_ceil(MAX_EQUITY_POINTS).max(1)
}
fn fold_peak(points: &[EquityPoint], running_peak_cents: &mut i64, worst_cents: &mut i64) {
for point in points {
if point.equity_cents > *running_peak_cents {
*running_peak_cents = point.equity_cents;
}
if let Some(drawdown) = point.equity_cents.checked_sub(*running_peak_cents)
&& drawdown < *worst_cents
{
*worst_cents = drawdown;
}
}
}
fn build_series(equity: &[EquityPoint]) -> GraphData {
let mut xs: Vec<Decimal> = Vec::new();
let mut ys: Vec<Decimal> = Vec::new();
push_sampled(equity, &mut xs, &mut ys);
GraphData::Series(Series2D {
x: xs,
y: ys,
name: EQUITY_SERIES_NAME.to_owned(),
mode: TraceMode::Lines,
line_color: None,
line_width: Some(2.0),
})
}
fn push_sampled(equity: &[EquityPoint], xs: &mut Vec<Decimal>, ys: &mut Vec<Decimal>) {
let n = equity.len();
let Some(last) = n.checked_sub(1) else {
return; };
let stride = stride_for(n);
let mut idx = 0usize;
let mut pushed_last = false;
while idx < n {
if let Some(point) = equity.get(idx) {
push_point(point, xs, ys);
if idx == last {
pushed_last = true;
}
}
idx = match idx.checked_add(stride) {
Some(next) => next,
None => break,
};
}
if !pushed_last && let Some(point) = equity.get(last) {
push_point(point, xs, ys);
}
}
fn append_series(
series: &mut Series2D,
equity: &[EquityPoint],
old_len: usize,
new_len: usize,
stride: usize,
) {
if old_len >= 1 && !(old_len - 1).is_multiple_of(stride) {
let _ = series.x.pop();
let _ = series.y.pop();
}
let mut idx = round_up_multiple(old_len, stride);
while idx < new_len {
if let Some(point) = equity.get(idx) {
push_point_into(series, point);
}
idx = match idx.checked_add(stride) {
Some(next) => next,
None => break,
};
}
if new_len >= 1
&& !(new_len - 1).is_multiple_of(stride)
&& let Some(point) = equity.get(new_len - 1)
{
push_point_into(series, point);
}
}
fn round_up_multiple(a: usize, stride: usize) -> usize {
let rem = a % stride;
if rem == 0 { a } else { a + (stride - rem) }
}
fn push_point(point: &EquityPoint, xs: &mut Vec<Decimal>, ys: &mut Vec<Decimal>) {
xs.push(Decimal::from(point.step));
ys.push(Decimal::from(point.equity_cents));
}
fn push_point_into(series: &mut Series2D, point: &EquityPoint) {
series.x.push(Decimal::from(point.step));
series.y.push(Decimal::from(point.equity_cents));
}
#[cfg(test)]
mod tests {
use optionstratlib::prelude::Decimal;
use optionstratlib::visualization::GraphData;
use super::{EQUITY_SERIES_NAME, EquityGeometry, MAX_EQUITY_POINTS};
use crate::replay::EquityPoint;
fn point(step: u32, equity_cents: i64) -> EquityPoint {
EquityPoint {
step,
ts_ns: 1_700_000_000_000_000_000 + i64::from(step),
cash_cents: equity_cents,
position_value_cents: 0,
equity_cents,
drawdown: 0.0,
}
}
#[track_caller]
fn xy(graph: &GraphData) -> (&Vec<Decimal>, &Vec<Decimal>) {
match graph {
GraphData::Series(series) => (&series.x, &series.y),
other => panic!("expected a single Series, got {other:?}"),
}
}
fn full_drawdown(equity: &[EquityPoint], seed: i64) -> i64 {
EquityGeometry::build(equity, seed).peak_drawdown_cents()
}
fn head(equity: &[EquityPoint], k: usize) -> &[EquityPoint] {
equity.get(..k).unwrap_or(equity)
}
#[test]
fn test_build_maps_step_to_equity_cents_in_order() {
let equity = vec![point(0, 1_000), point(1, 1_050), point(2, 990)];
let geo = EquityGeometry::build(&equity, 0);
let (x, y) = xy(geo.graph());
assert_eq!(x.len(), 3, "one sample per step");
assert_eq!(y.len(), x.len(), "x and y are paired");
assert_eq!(x, &[Decimal::from(0), Decimal::from(1), Decimal::from(2)]);
assert_eq!(
y,
&[
Decimal::from(1_000),
Decimal::from(1_050),
Decimal::from(990)
],
"y is exact integer cents, never dollars",
);
}
#[test]
fn test_build_empty_is_empty_series() {
let geo = EquityGeometry::build(&[], 0);
let (x, y) = xy(geo.graph());
assert!(x.is_empty() && y.is_empty());
assert_eq!(geo.peak_drawdown_cents(), 0, "no rows → no drawdown");
match geo.graph() {
GraphData::Series(series) => assert_eq!(series.name, EQUITY_SERIES_NAME),
other => panic!("expected a Series, got {other:?}"),
}
}
#[test]
fn test_build_downsamples_but_keeps_first_and_last() {
let n = MAX_EQUITY_POINTS * 3 + 7;
let equity: Vec<EquityPoint> = (0..n)
.map(|i| {
point(
u32::try_from(i).unwrap_or(u32::MAX),
1_000 + i64::try_from(i).unwrap_or(0),
)
})
.collect();
let geo = EquityGeometry::build(&equity, 0);
let (x, y) = xy(geo.graph());
assert!(
x.len() <= MAX_EQUITY_POINTS,
"downsampled at or below the cap"
);
assert_eq!(x.len(), y.len());
assert_eq!(x.first(), Some(&Decimal::from(0)), "first step retained");
let last_step = u32::try_from(n - 1).unwrap_or(u32::MAX);
assert_eq!(
x.last(),
Some(&Decimal::from(last_step)),
"last step retained"
);
}
#[test]
fn test_peak_drawdown_is_the_worst_dip_from_the_running_peak() {
let equity = vec![
point(0, 1_000),
point(1, 1_200),
point(2, 900),
point(3, 1_100),
point(4, 800),
];
assert_eq!(full_drawdown(&equity, 1_000), -400);
}
#[test]
fn test_peak_drawdown_zero_when_monotonic_or_empty() {
assert_eq!(full_drawdown(&[], 0), 0, "empty run has no drawdown");
let climbing = vec![point(0, 100), point(1, 200), point(2, 300)];
assert_eq!(
full_drawdown(&climbing, 100),
0,
"a run that only climbs from the opening balance never draws down",
);
}
#[test]
fn test_peak_drawdown_handles_negative_equity() {
let equity = vec![point(0, 500), point(1, -300)];
assert_eq!(full_drawdown(&equity, 500), -800);
}
#[test]
fn test_peak_drawdown_seeded_from_opening_capital_shows_step0_loss() {
let equity = vec![point(0, 990_000), point(1, 1_050_000)];
assert_eq!(
full_drawdown(&equity, 1_000_000),
-10_000,
"step-0 loss is a drawdown vs opening capital, not $0",
);
assert_eq!(
full_drawdown(&equity, 990_000),
0,
"seeded from the first row, the step-0 loss is (wrongly) invisible",
);
}
#[test]
fn test_extend_forward_matches_full_rebuild_at_every_head() {
let n = MAX_EQUITY_POINTS * 2 + 40;
let seed = 1_000_000i64;
let equity: Vec<EquityPoint> = (0..n)
.map(|i| {
let wobble = (i64::try_from(i % 41).unwrap_or(0) - 20) * 1_500;
point(u32::try_from(i).unwrap_or(u32::MAX), 1_000_000 + wobble)
})
.collect();
let mut inc = EquityGeometry::build(head(&equity, 1), seed);
for head_len in 2..=n {
inc.extend_forward(head(&equity, head_len));
let full = EquityGeometry::build(head(&equity, head_len), seed);
assert_eq!(
xy(inc.graph()),
xy(full.graph()),
"series mismatch at head {head_len} (incremental != full)",
);
assert_eq!(
inc.peak_drawdown_cents(),
full.peak_drawdown_cents(),
"peak mismatch at head {head_len} (incremental != full)",
);
assert_eq!(inc.raw_len(), head_len, "raw_len tracks the head");
}
}
#[test]
fn test_extend_forward_no_new_rows_is_a_noop() {
let equity = vec![point(0, 1_000), point(1, 1_050)];
let mut geo = EquityGeometry::build(&equity, 1_000);
let before = xy(geo.graph()).0.clone();
let peak_before = geo.peak_drawdown_cents();
geo.extend_forward(&equity); assert_eq!(xy(geo.graph()).0, &before);
assert_eq!(geo.peak_drawdown_cents(), peak_before);
assert_eq!(geo.raw_len(), equity.len());
}
#[test]
fn test_rebuild_recomputes_from_seed_on_a_backward_seek() {
let seed = 1_000_000i64;
let equity: Vec<EquityPoint> = (0..40)
.map(|i| point(i, 1_000_000 - i64::from(i) * 2_000))
.collect();
let mut geo = EquityGeometry::build(head(&equity, 1), seed);
geo.extend_forward(head(&equity, 30));
geo.rebuild(head(&equity, 5));
let full = EquityGeometry::build(head(&equity, 5), seed);
assert_eq!(xy(geo.graph()), xy(full.graph()), "backward rebuild series");
assert_eq!(
geo.peak_drawdown_cents(),
full.peak_drawdown_cents(),
"backward rebuild peak (seed preserved)",
);
assert_eq!(geo.raw_len(), 5);
}
}