diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index b46f4d217..de01ba043 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -90,6 +90,8 @@ struct FnCtx<'a, 'h> { ev: &'a mut Evaluator<'h>, /// The criteria argument of a database function, when it is a reference. criteria: Option, + /// The LAMBDA given as the function argument of GROUPBY/PIVOTBY. + lambda: Option>, } impl Ctx for FnCtx<'_, '_> { @@ -124,6 +126,19 @@ impl Ctx for FnCtx<'_, '_> { let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) } + fn has_lambda(&self) -> bool { + self.lambda.is_some() + } + /// A LAMBDA must give one value per group; an array result is #CALC!. + fn call_lambda(&mut self, arg: Value) -> Option { + let l = self.lambda.clone()?; + let [p] = l.params.as_slice() else { return Some(Value::Error(CellError::Value)) }; + let r = self.ev.call_lambda_values(&l, vec![(p.clone(), Ev::V(arg))]); + Some(match self.ev.deref(r) { + Value::Array(a) if a.data.len() != 1 => Value::Error(CellError::Calc), + v => v.scalar(), + }) + } } impl<'h> Evaluator<'h> { @@ -618,7 +633,7 @@ impl<'h> Evaluator<'h> { let value = self.value(a); evaluated.push(Arg { value, from_ref: false }); } - let mut ctx = FnCtx { ev: self, criteria: None }; + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); } let i = match index.to_number() { @@ -817,8 +832,21 @@ impl<'h> Evaluator<'h> { let mut criteria_shape = None; // Database functions: the criteria range (argument 2), whose formulas are computed criteria. let mut criteria = None; + // GROUPBY/PIVOTBY: the LAMBDA given as their function. + let mut lambda = None; + let lambda_at = match name { + "GROUPBY" => Some(2), + "PIVOTBY" => Some(3), + _ => None, + }; for (i, a) in args.iter().enumerate() { let mut ev = self.eval(a); + if lambda_at == Some(i) + && let Ev::L(l) = &ev + { + lambda = Some(l.clone()); + ev = Ev::V(Value::Empty); + } if spec.category == gridcraft_functions::Category::Database && i == 2 && let Ev::R(areas) = &ev @@ -853,7 +881,7 @@ impl<'h> Evaluator<'h> { }; evaluated.push(Arg { value, from_ref }); } - let mut ctx = FnCtx { ev: self, criteria }; + let mut ctx = FnCtx { ev: self, criteria, lambda }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } @@ -907,13 +935,13 @@ impl<'h> Evaluator<'h> { }; let args = [Arg { value, from_ref }]; if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { Value::Number(n) => acc.sum += n, other => return Err(Ev::V(other)), } } if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), other => return Err(Ev::V(other)), } @@ -1012,15 +1040,18 @@ impl<'h> Evaluator<'h> { let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); - if (rows * cols) as u64 > MAX_CELLS { - return err(CellError::Num); + let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; + // The LAMBDA takes one parameter per array. + if l.params.len() != arrays.len() { + return err(CellError::Value); } - let mut out = Vec::with_capacity(rows * cols); + let mut out = Vec::with_capacity(n); for r in 0..rows { for c in 0..cols { let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); let v = self.call_lambda_values(&l, bound); - out.push(self.deref(v).scalar()); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); } } Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) @@ -1041,7 +1072,8 @@ impl<'h> Evaluator<'h> { let r = self.call_lambda_values(&l, bound); acc = self.deref(r); if name == "SCAN" { - scan.push(acc.scalar()); + let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; + scan.push(one); } } if name == "SCAN" { @@ -1057,6 +1089,9 @@ impl<'h> Evaluator<'h> { let arr = as_array(self.value(&args[0])); let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; + if l.params.len() != 1 { + return err(CellError::Value); + } let by_row = name == "BYROW"; let n = if by_row { arr.rows } else { arr.cols }; let mut out = Vec::with_capacity(n); @@ -1068,7 +1103,8 @@ impl<'h> Evaluator<'h> { }; let a = if by_row { Array::row(slice) } else { Array::column(slice) }; let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); - out.push(self.deref(r).scalar()); + let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; + out.push(one); } let a = if by_row { Array::column(out) } else { Array::row(out) }; Ev::V(a.into()) @@ -1097,7 +1133,8 @@ impl<'h> Evaluator<'h> { (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), ]; let v = self.call_lambda_values(&l, bound); - out.push(self.deref(v).scalar()); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); } } Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) @@ -1318,7 +1355,7 @@ impl<'h> Evaluator<'h> { } } } - let mut ctx = FnCtx { ev: self, criteria: None }; + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; return match gridcraft_functions::call(spec, &xargs, &mut ctx) { Value::Number(p) if p >= 1.0 => { let k = p as u32 - 1; @@ -1431,43 +1468,74 @@ impl<'h> Evaluator<'h> { if name == "AGGREGATE" && func >= 14 { k = rest.get(1).map(|e| self.value(e)); } + // Every cell visited (even one skipped as hidden, nested or an error) and every value + // counts against one budget for the whole call. All arguments are evaluated (references + // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes + // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). + enum Input { + Cells(usize, RangeRef), + Values(Value), + } + let mut inputs = Vec::new(); + let mut budget = MAX_CELLS; for e in refs { match self.eval(e) { Ev::R(areas) => { for a in areas { let range = self.trim(&a); - for c in range.iter() { - let (hidden, nested) = { - let wb = self.host.workbook(); - let s = wb.sheet(a.sheet); - let hidden = - s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); - let nested = skip_nested - && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { - let u = f.text.to_ascii_uppercase(); - u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") - }); - (hidden, nested) - }; - if hidden || nested { - continue; - } - let v = self.host.cell_value(a.sheet, c); - if skip_errors && v.is_error() { - continue; - } - values.push(v); - } + budget = match budget.checked_sub(range.count()) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Cells(a.sheet, range)); } } - Ev::V(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), - Ev::V(v) => values.push(v), + Ev::V(v) => { + let n = match &v { + Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), + _ => 1, + }; + budget = match budget.checked_sub(n) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Values(v)); + } Ev::L(_) => return err(CellError::Value), } } + for input in inputs { + match input { + Input::Cells(sheet, range) => { + for c in range.iter() { + let (hidden, nested) = { + let wb = self.host.workbook(); + let s = wb.sheet(sheet); + let hidden = + s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); + let nested = skip_nested + && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { + let u = f.text.to_ascii_uppercase(); + u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") + }); + (hidden, nested) + }; + if hidden || nested { + continue; + } + let v = self.host.cell_value(sheet, c); + if skip_errors && v.is_error() { + continue; + } + values.push(v); + } + } + Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), + Input::Values(v) => values.push(v), + } + } if let Some(k) = k { - values.push(Value::Empty); - return Ev::V(gridcraft_functions::aggregate_values_k(func, &values[..values.len() - 1], &k)); + return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); } Ev::V(gridcraft_functions::aggregate_values(func, &values)) } @@ -1624,7 +1692,8 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value (Value::Array(x), Value::Array(y)) => { let rows = broadcast_dim(x.rows, y.rows); let cols = broadcast_dim(x.cols, y.cols); - let mut data = Vec::with_capacity(rows * cols); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let mut data = Vec::with_capacity(n); for r in 0..rows { for c in 0..cols { data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); @@ -1638,6 +1707,13 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value } } +/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. +/// Each operand passes its own size cap, but their broadcast product can be far larger +/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +fn broadcast_len(rows: usize, cols: usize) -> Option { + rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) +} + fn broadcast_dim(a: usize, b: usize) -> usize { if a == 1 { b @@ -1654,8 +1730,9 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V let (fr, fc) = dims(f); let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; - let mut data = Vec::with_capacity(rows * cols); + let mut data = Vec::with_capacity(n); for r in 0..rows { for col in 0..cols { data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); @@ -1664,6 +1741,15 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V Value::from(Array { rows, cols, data }) } +/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays +/// can't nest, so the whole helper gives `#CALC!`, as in Excel. +fn single(v: Value) -> Option { + match v { + Value::Array(a) if a.rows * a.cols > 1 => None, + v => Some(v.scalar()), + } +} + pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 4e5486fbe..88061120c 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,29 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +#[test] +fn lambda_helpers_check_shapes_and_parameters() { + let mut t = T::new(); + let calc = Value::Error(CellError::Calc); + // A LAMBDA returning several values can't fill one cell: the whole result is #CALC!. + t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))"); + assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty)); + t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))"); + assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty)); + t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))"); + assert_eq!(t.get("A4"), calc.clone()); + t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))"); + assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty)); + // One parameter per array (MAP) or exactly one (BYROW/BYCOL). + t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))"); + assert_eq!(t.get("A5"), Value::Error(CellError::Value)); + t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))"); + assert_eq!(t.get("A6"), Value::Error(CellError::Value)); + // A single value in an array is fine. + t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))"); + assert_eq!(t.num("B7"), 2.0); +} + #[test] fn operators() { let mut t = T::new(); @@ -632,6 +655,36 @@ fn functions_given_by_name() { assert_eq!(t.num("J1"), 20.0); } +#[test] +fn groupby_and_pivotby_take_a_lambda() { + let mut t = T::new(); + for (i, (k, c, v)) in [("a", "x", 1), ("b", "x", 2), ("a", "y", 3)].iter().enumerate() { + t.set(&format!("A{}", i + 1), k); + t.set(&format!("B{}", i + 1), c); + t.set(&format!("C{}", i + 1), &v.to_string()); + } + t.set("E1", "=GROUPBY(A1:A3,C1:C3,LAMBDA(x,SUM(x)))"); + assert_eq!(t.get("E1"), Value::from("a")); + assert_eq!(t.num("F1"), 4.0); + assert_eq!(t.get("E2"), Value::from("b")); + assert_eq!(t.num("F2"), 2.0); + assert_eq!(t.get("E3"), Value::from("Total")); + assert_eq!(t.num("F3"), 6.0); + // A LET-bound lambda, called once per group with that group's values. + t.set("H1", "=LET(f,LAMBDA(v,ROWS(v)*10),GROUPBY(A1:A3,C1:C3,f))"); + assert_eq!(t.num("I1"), 20.0); + assert_eq!(t.num("I3"), 30.0); + // PIVOTBY: rows a, b, Total; columns x, y, Total. + t.set("K1", "=PIVOTBY(A1:A3,B1:B3,C1:C3,LAMBDA(x,SUM(x)))"); + assert_eq!(t.get("L1"), Value::from("x")); + assert_eq!(t.num("L2"), 1.0); + assert_eq!(t.num("M2"), 3.0); + assert_eq!(t.num("N4"), 6.0); + // Neither a function nor a lambda. + t.set("P1", "=GROUPBY(A1:A3,C1:C3,TRUE)"); + assert_eq!(t.get("P1"), Value::Error(CellError::Value)); +} + #[test] fn lambda_argument_count() { let mut t = T::new(); @@ -723,6 +776,34 @@ fn legacy_array_formulas_fill_their_range() { assert_eq!(t.get("H1"), Value::Error(CellError::Num)); } +#[test] +fn array_broadcast_result_is_capped() { + // A full column against a full row broadcasts to 1048576 x 16384 (17 billion cells, about + // 400 GB): each operand is within its own cap, so only the product check stops the abort. + let mut t = T::new(); + let col = "SEQUENCE(1048576)"; + let row = "SEQUENCE(1,16384)"; + for (at, formula) in [ + ("A1", format!("=ROWS({col}*{row})")), + ("A2", format!("=ROWS({col}&{row})")), + ("A3", format!("=ROWS({col}>{row})")), + ("A4", format!("=ROWS(IF({col},{row}))")), + ("A5", format!("=ROWS(IF({col}>0,1,{row}))")), + ("A6", format!("=ROWS(TEXT({col},{row}))")), + ("A7", format!("=ROWS(MAP({col},{row},LAMBDA(a,b,a)))")), + ] { + t.set(at, &formula); + assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}"); + } + // Broadcasts within the cap are unchanged. + t.set("C1", "=SUM(SEQUENCE(3)*SEQUENCE(1,4))"); + assert_eq!(t.num("C1"), 60.0); + t.set("C2", "=SUM(IF(SEQUENCE(2)>1,SEQUENCE(1,3),0))"); + assert_eq!(t.num("C2"), 6.0); + t.set("C3", "=COLUMNS(TEXT(SEQUENCE(2),SEQUENCE(1,5)))"); + assert_eq!(t.num("C3"), 5.0); +} + #[test] fn text_function() { let mut t = T::new(); @@ -812,6 +893,40 @@ fn subtotal_109_skips_nested_subtotal() { assert_eq!(t.num("B2"), 3.0); } +/// One value stored in the sheet's last cell makes `A:XFD` trim to ~17 billion cells. SUBTOTAL +/// and AGGREGATE used to read every one of them into a vector (an abort on open, #2175); now +/// every argument is charged against `MAX_CELLS` before any cell is read, and `#NUM!` comes back. +#[test] +fn subtotal_and_aggregate_cap_cells_visited() { + let mut t = T::new(); + t.set("XFD1048576", "1"); + let formulas = [ + "=SUBTOTAL(9,A:XFD)", + "=SUBTOTAL(109,1:1048576)", + "=AGGREGATE(9,6,A:XFD)", + "=AGGREGATE(14,7,A:XFD,1)", + // A:J and K:T are about 10.5 million cells each: under the limit alone, over it together, + // so the second area is refused before the first is read. + "=SUBTOTAL(9,A:J,K:T)", + "=AGGREGATE(9,4,(A:J,K:T))", + ]; + for (i, f) in formulas.iter().enumerate() { + let at = format!("B{}", i + 1); + t.set(&at, f); + assert_eq!(t.get(&at), Value::Error(CellError::Num), "{f}"); + } + + // Whole columns and rows still clip to the used range when it is small. + let mut t = T::new(); + t.set("A1", "1"); + t.set("B2", "2"); + t.set("C2", "#N/A"); + t.set("E5", "=SUBTOTAL(9,A:B)"); + t.set("E6", "=AGGREGATE(9,6,1:2)"); + t.set("E7", "=AGGREGATE(14,6,A:C,1)"); + assert_eq!((t.num("E5"), t.num("E6"), t.num("E7")), (3.0, 3.0, 2.0)); +} + /// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows /// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])` /// under a filtered table adds only the visible rows). @@ -1405,3 +1520,20 @@ fn cell_filename_names_the_workbook_file() { t.set("B7", "=CELL(\"filename\",A1)"); assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info")); } + +#[test] +fn n_lifts_over_a_computed_array_but_reads_the_first_cell_of_a_range() { + let mut t = T::new(); + t.set("B1", "0"); + t.set("B2", "23850"); + t.set("D2", "10%"); + t.set("D3", "12%"); + t.set("F1", "50000"); + t.set("H1", "=SUMPRODUCT(D2:D3-D1:D2,F1-B1:B2,N(F1>B1:B2))"); + assert!((t.num("H1") - 5523.0).abs() < 1e-9, "{:?}", t.get("H1")); + t.set("H2", "=SUMPRODUCT(N(B1:B2>0))"); + assert_eq!(t.num("H2"), 1.0); + // A spill of N(D2:D3) would also show 10% in H3; summing it tells the two apart. + t.set("H3", "=SUM(N(D2:D3))"); + assert!((t.num("H3") - 0.1).abs() < 1e-12, "{:?}", t.get("H3")); +} diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 67c56999e..05638b2b7 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -13,16 +13,16 @@ const ND: &[bool] = &[true, true, false]; const NDI: &[bool] = &[true, true, true, false]; /// A date serial argument (truncated to whole days when `whole`). Out of range → `#NUM!`. -fn serial_arg(a: &[Arg], i: usize) -> R { - let n = num(a, i)?; +fn serial_arg(a: &[Arg], i: usize, sys: DateSystem) -> R { + let n = scalar(arg(a, i)?).to_number_in(sys)?; if !(0.0..MAX_SERIAL + 1.0).contains(&n) { return Err(CellError::Num); } Ok(n) } -fn day_arg(a: &[Arg], i: usize) -> R { - Ok(serial_arg(a, i)?.floor() as i64) +fn day_arg(a: &[Arg], i: usize, sys: DateSystem) -> R { + Ok(serial_arg(a, i, sys)?.floor() as i64) } fn dt(sys: DateSystem, serial: f64) -> R { @@ -66,8 +66,9 @@ fn time(a: &[Arg], _c: &mut dyn Ctx) -> R { } fn part(a: &[Arg], c: &mut dyn Ctx, f: fn(&DateTime) -> f64) -> R { - let s = serial_arg(a, 0)?; - num_val(f(&dt(c.date_system(), s.floor())?)) + let sys = c.date_system(); + let s = serial_arg(a, 0, sys)?; + num_val(f(&dt(sys, s.floor())?)) } fn year(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -81,20 +82,20 @@ fn day(a: &[Arg], c: &mut dyn Ctx) -> R { } /// Seconds into the day, rounded to the nearest second. -fn day_seconds(a: &[Arg]) -> R { - let s = serial_arg(a, 0)?; +fn day_seconds(a: &[Arg], sys: DateSystem) -> R { + let s = serial_arg(a, 0, sys)?; let secs = ((s - s.floor()) * 86400.0).round() as i64; Ok(if secs >= 86400 { 0 } else { secs }) } -fn hour(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_seconds(a)? / 3600) as f64) +fn hour(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val((day_seconds(a, c.date_system())? / 3600) as f64) } -fn minute(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_seconds(a)? / 60 % 60) as f64) +fn minute(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val((day_seconds(a, c.date_system())? / 60 % 60) as f64) } -fn second(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_seconds(a)? % 60) as f64) +fn second(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val((day_seconds(a, c.date_system())? % 60) as f64) } fn now(_a: &[Arg], c: &mut dyn Ctx) -> R { @@ -105,9 +106,10 @@ fn today(_a: &[Arg], c: &mut dyn Ctx) -> R { } fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R { - let s = day_arg(a, 0)?; + let sys = c.date_system(); + let s = day_arg(a, 0, sys)?; let t = to_int(opt_num(a, 1, 1.0)?)?; - let wd = weekday0(c.date_system(), s) as i64; + let wd = weekday0(sys, s) as i64; let r = match t { 1 | 17 => wd + 1, 2 | 11 => (wd + 6) % 7 + 1, @@ -128,7 +130,7 @@ fn isoweek(sys: DateSystem, s: i64) -> R { fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let s = day_arg(a, 0)?; + let s = day_arg(a, 0, sys)?; let t = to_int(opt_num(a, 1, 1.0)?)?; let start = match t { 1 | 17 => 0, @@ -144,7 +146,8 @@ fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R { } fn isoweeknum(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(isoweek(c.date_system(), day_arg(a, 0)?)? as f64) + let sys = c.date_system(); + num_val(isoweek(sys, day_arg(a, 0, sys)?)? as f64) } fn months_arg(a: &[Arg], i: usize) -> R { @@ -157,7 +160,7 @@ fn months_arg(a: &[Arg], i: usize) -> R { fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let d = dt(sys, day_arg(a, 0)? as f64)?; + let d = dt(sys, day_arg(a, 0, sys)? as f64)?; let m = months_arg(a, 1)?; let total = d.year as i64 * 12 + d.month as i64 - 1 + m; let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1); @@ -172,7 +175,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let d = dt(sys, day_arg(a, 0)? as f64)?; + let d = dt(sys, day_arg(a, 0, sys)? as f64)?; let m = months_arg(a, 1)?; let total = d.year as i64 * 12 + d.month as i64 - 1 + m; let (y, mo) = (total.div_euclid(12), total.rem_euclid(12) + 1); @@ -185,8 +188,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let s = day_arg(a, 0)?; - let e = day_arg(a, 1)?; + let s = day_arg(a, 0, sys)?; + let e = day_arg(a, 1, sys)?; let unit = text(a, 2)?.to_ascii_uppercase(); if s > e { return Err(CellError::Num); @@ -247,8 +250,9 @@ fn timevalue(a: &[Arg], c: &mut dyn Ctx) -> R { num_val(n - n.floor()) } -fn days(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val((day_arg(a, 0)? - day_arg(a, 1)?) as f64) +fn days(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + num_val((day_arg(a, 0, sys)? - day_arg(a, 1, sys)?) as f64) } fn last_of_feb(d: &DateTime) -> bool { @@ -257,8 +261,8 @@ fn last_of_feb(d: &DateTime) -> bool { fn days360(a: &[Arg], c: &mut dyn Ctx) -> R { let sys = c.date_system(); - let d1 = dt(sys, day_arg(a, 0)? as f64)?; - let d2 = dt(sys, day_arg(a, 1)? as f64)?; + let d1 = dt(sys, day_arg(a, 0, sys)? as f64)?; + let d2 = dt(sys, day_arg(a, 1, sys)? as f64)?; let european = opt_bool(a, 2, false)?; num_val(days360_core(&d1, &d2, european) as f64) } @@ -286,7 +290,10 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> { Value::Empty if !arg.from_ref => Ok([true, false, false, false, false, false, true]), Value::Text(t) => { let b = t.as_bytes(); - if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') || b.iter().all(|c| *c == b'1') { + // All seven days marked as the weekend is a valid string: Microsoft documents + // NETWORKDAYS.INTL as "Using 1111111 will always return 0" and WORKDAY.INTL as + // rejecting it, which `workday_core`'s `per_week == 0` guard already implements. + if b.len() != 7 || !b.iter().all(|c| *c == b'0' || *c == b'1') { return Err(CellError::Value); } // String order is Monday..Sunday. @@ -313,7 +320,7 @@ fn weekend_mask(v: Option<&Arg>) -> R<[bool; 7]> { } } -fn holidays(v: Option<&Arg>) -> R> { +fn holidays(v: Option<&Arg>, sys: DateSystem) -> R> { let mut out = Vec::new(); if let Some(a) = v { for x in as_array(&a.value).iter() { @@ -321,7 +328,7 @@ fn holidays(v: Option<&Arg>) -> R> { Value::Empty => {} Value::Error(e) => return Err(*e), other => { - let n = other.to_number()?; + let n = other.to_number_in(sys)?; if !(0.0..MAX_SERIAL + 1.0).contains(&n) { return Err(CellError::Num); } @@ -353,9 +360,9 @@ fn count_workdays(sys: DateSystem, a: i64, b: i64, mask: &[bool; 7]) -> i64 { fn networkdays_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R { let sys = c.date_system(); - let s = day_arg(a, 0)?; - let e = day_arg(a, 1)?; - let hs = holidays(hol)?; + let s = day_arg(a, 0, sys)?; + let e = day_arg(a, 1, sys)?; + let hs = holidays(hol, sys)?; let (lo, hi, sign) = if s <= e { (s, e, 1) } else { (e, s, -1) }; let mut n = count_workdays(sys, lo, hi, &mask); n -= hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64; @@ -387,13 +394,13 @@ fn advance(sys: DateSystem, start: i64, n: i64, dir: i64, mask: &[bool; 7], per_ fn workday_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) -> R { let sys = c.date_system(); - let start = day_arg(a, 0)?; + let start = day_arg(a, 0, sys)?; let days = num(a, 1)?.trunc(); if days.abs() > 1e7 { return Err(CellError::Num); } let days = days as i64; - let hs = holidays(hol)?; + let hs = holidays(hol, sys)?; let per_week = mask.iter().filter(|w| !**w).count() as i64; if per_week == 0 { return Err(CellError::Value); @@ -476,10 +483,11 @@ fn feb29_between(sys: DateSystem, s: i64, e: i64, d1: &DateTime, d2: &DateTime) } fn yearfrac(a: &[Arg], c: &mut dyn Ctx) -> R { - let s = day_arg(a, 0)?; - let e = day_arg(a, 1)?; + let sys = c.date_system(); + let s = day_arg(a, 0, sys)?; + let e = day_arg(a, 1, sys)?; let basis = if has(a, 2) { to_int(num(a, 2)?)? } else { 0 }; - num_val(yearfrac_core(c.date_system(), s, e, basis)?) + num_val(yearfrac_core(sys, s, e, basis)?) } pub(crate) fn specs() -> Vec { @@ -766,7 +774,10 @@ mod tests { close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), n(7.0)]), 24.0); close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 2.0, 1.0)), t("0000110")]), 24.0); close(ev("NETWORKDAYS.INTL", vec![n(d(2006.0, 1.0, 1.0)), n(d(2006.0, 1.0, 31.0)), n(11.0)]), 26.0); - is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value); + // An all-weekend string is valid: no day is a workday, so the count is 0. + close(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), t("1111111")]), 0.0); + // WORKDAY.INTL rejects it (there is no working day to move to). + is_err(ev("WORKDAY.INTL", vec![n(1.0), n(10.0), t("1111111")]), CellError::Value); is_err(ev("NETWORKDAYS.INTL", vec![n(1.0), n(10.0), n(8.0)]), CellError::Num); // WORKDAY close(ev("WORKDAY", vec![n(d(2008.0, 10.0, 1.0)), n(151.0)]), d(2009.0, 4.0, 30.0)); @@ -832,4 +843,15 @@ mod tests { close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0); is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value); } + + #[test] + fn date_text_arguments_use_the_workbook_date_system() { + let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() }; + close(crate::call(crate::lookup("YEAR").unwrap(), &[t("1/1/2020")], &mut ctx), 2020.0); + close(crate::call(crate::lookup("MONTH").unwrap(), &[t("2/29/1904")], &mut ctx), 2.0); + close(crate::call(crate::lookup("DAY").unwrap(), &[t("2/29/1904")], &mut ctx), 29.0); + close(crate::call(crate::lookup("DAYS").unwrap(), &[t("1/2/2020"), t("1/1/2020")], &mut ctx), 1.0); + let networkdays = crate::lookup("NETWORKDAYS").unwrap(); + close(crate::call(networkdays, &[t("1/6/2020"), t("1/10/2020"), t("1/8/2020")], &mut ctx), 4.0); + } } diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 2e29b416c..ee3e10a3e 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -86,8 +86,11 @@ fn key_of(v: &Value) -> String { fn row_key(r: &[Value]) -> String { let mut s = String::new(); for v in r { - s.push_str(&key_of(v)); - s.push('\u{1}'); + let k = key_of(v); + // Length prefix keeps the encoding unambiguous even when a text value contains the separator. + s.push_str(&k.len().to_string()); + s.push(':'); + s.push_str(&k); } s } @@ -573,6 +576,23 @@ fn parse_agg(v: &Value) -> R { } } +/// The function argument of GROUPBY/PIVOTBY: a built-in aggregation, or `None` for a LAMBDA (called +/// through the context). +fn agg_arg(args: &[Arg], i: usize, c: &dyn Ctx) -> R> { + if c.has_lambda() { Ok(None) } else { parse_agg(&arg(args, i)?.value).map(Some) } +} + +/// Aggregates one group with `f`, or with the LAMBDA (given the group's values as a column) when `None`. +fn aggregate_with(f: Option, vals: &[&Value], c: &mut dyn Ctx) -> Value { + match f { + Some(f) => aggregate(f, vals), + None => { + let col = Value::from(Array::column(vals.iter().map(|v| (*v).clone()).collect())); + c.call_lambda(col).unwrap_or(Value::Error(CellError::Calc)) + } + } +} + fn aggregate(f: Agg, vals: &[&Value]) -> Value { if f == Agg::CountA { return Value::Number(vals.iter().filter(|v| !matches!(v, Value::Empty)).count() as f64); @@ -732,10 +752,10 @@ fn opt_arr(args: &[Arg], i: usize) -> R> { if has(args, i) { Ok(Some(arr_arg(args, i)?)) } else { Ok(None) } } -fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R { +fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R { let rf = arr_arg(args, 0)?; let vals = arr_arg(args, 1)?; - let f = parse_agg(&arg(args, 2)?.value)?; + let f = agg_arg(args, 2, c)?; let fh = opt_count(args, 3)?; let total = opt_count(args, 4)?.unwrap_or(1); let sort = opt_count(args, 5)?.unwrap_or(1); @@ -748,11 +768,11 @@ fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R { } let sort_in_keys = if sort.unsigned_abs() as usize <= rf.cols { sort } else { 0 }; let mut ordered = distinct_keys(&keys, sort_in_keys); - let agg_row = |rows: &[usize]| -> Vec { + let mut agg_row = |rows: &[usize]| -> Vec { (0..vals.cols) - .map(|c| { - let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, c)).collect(); - aggregate(f, &vs) + .map(|col| { + let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, col)).collect(); + aggregate_with(f, &vs, c) }) .collect() }; @@ -798,11 +818,11 @@ fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: (header_rows.saturating_add(body_rows), col_keys.saturating_mul(value_cols).saturating_add(lead)) } -fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { +fn pivotby(args: &[Arg], ctx: &mut dyn Ctx) -> R { let rf = arr_arg(args, 0)?; let cf = arr_arg(args, 1)?; let vals = arr_arg(args, 2)?; - let f = parse_agg(&arg(args, 3)?.value)?; + let f = agg_arg(args, 3, ctx)?; let fh = opt_count(args, 4)?; let row_total = opt_count(args, 5)?.unwrap_or(1); let row_sort = opt_count(args, 6)?.unwrap_or(1); @@ -818,12 +838,12 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) { cell.entry((row_key(r), row_key(c))).or_default().push(i); } - let agg = |rows: &[usize], c: usize| -> Value { + let mut agg = |rows: &[usize], c: usize| -> Value { if rows.is_empty() { return Value::Empty; } let vs: Vec<&Value> = rows.iter().filter_map(|&r| vals.get(r, c)).collect(); - aggregate(f, &vs) + aggregate_with(f, &vs, ctx) }; let rows_for = |r: Option<&Vec>, c: Option<&Vec>| -> Vec { let rkey = r.map(|k| row_key(k)); @@ -873,7 +893,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R { } out.push(line); } - let body_line = |r: Option<&Vec>| -> Vec { + let mut body_line = |r: Option<&Vec>| -> Vec { let mut line: Vec = match r { Some(k) => k.clone(), None => { @@ -1102,6 +1122,12 @@ mod tests { is_err(ev("SORTBY", vec![av(col(&[1.0, 2.0])), av(col(&[1.0, 2.0])), n(0.0)]), CellError::Value); } + #[test] + fn unique_keeps_rows_containing_separator_apart() { + let m = arr(vec![vec![tv("a"), tv("b\u{1}tc")], vec![tv("a\u{1}tb"), tv("c")]]); + assert_eq!(rows_of(&ev("UNIQUE", vec![av(m)])).len(), 2); + } + #[test] fn unique() { let v = arr(vec![vec![tv("a")], vec![tv("B")], vec![tv("A")], vec![tv("c")], vec![tv("b")]]); diff --git a/crates/functions/src/ets.rs b/crates/functions/src/ets.rs index 617b171ac..90afb4382 100644 --- a/crates/functions/src/ets.rs +++ b/crates/functions/src/ets.rs @@ -374,14 +374,21 @@ pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: Date /// value at either end). fn interpolate(slots: &[Option]) -> Vec { let mut out = Vec::with_capacity(slots.len()); + // Keep the next known point until it is reached, scanning each slot only once for lookahead. + let mut known = slots.iter().enumerate().filter_map(|(j, s)| { + #[cfg(test)] + tests::INTERPOLATION_VISITS.with(|visits| visits.set(visits.get() + 1)); + s.map(|v| (j, v)) + }); + let mut next = known.next(); let mut prev: Option<(usize, f64)> = None; for (i, s) in slots.iter().enumerate() { if let Some(v) = s { out.push(*v); prev = Some((i, *v)); + next = known.next(); continue; } - let next = slots.iter().enumerate().skip(i + 1).find_map(|(j, s)| s.map(|v| (j, v))); out.push(match (prev, next) { (Some((a, va)), Some((b, vb))) => va + (vb - va) * (i - a) as f64 / (b - a) as f64, (Some((_, v)), None) | (None, Some((_, v))) => v, @@ -925,6 +932,72 @@ mod tests { use super::*; use crate::util::testutil::*; + thread_local! { + pub(super) static INTERPOLATION_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[test] + fn interpolation_preserves_gaps_and_endpoint_values() { + for (slots, expected) in [ + ( + vec![None, Some(4.0), None, None, Some(13.0), Some(-2.0), None, Some(8.0), None, None], + vec![4.0, 4.0, 7.0, 10.0, 13.0, -2.0, 3.0, 8.0, 8.0, 8.0], + ), + (vec![Some(-7.0), None, Some(4.0)], vec![-7.0, -1.5, 4.0]), + (vec![Some(3.0), Some(-7.0), Some(2.0)], vec![3.0, -7.0, 2.0]), + (vec![None, None, None], vec![0.0, 0.0, 0.0]), + (vec![Some(6.0)], vec![6.0]), + (vec![], vec![]), + ] { + assert_eq!(interpolate(&slots), expected, "{slots:?}"); + } + } + + #[test] + fn interpolation_lookahead_work_is_linear() { + let mut measured = Vec::new(); + for len in [2_000, 4_000] { + let missing = len * 3 / 10; + let gap = (len - missing) / 2..(len + missing) / 2; + let expected: Vec = (0..len).map(|i| (3 * i + 5) as f64).collect(); + let slots: Vec> = expected.iter().enumerate().map(|(i, &v)| (!gap.contains(&i)).then_some(v)).collect(); + INTERPOLATION_VISITS.with(|visits| visits.set(0)); + let actual = interpolate(&slots); + let visits = INTERPOLATION_VISITS.with(|visits| visits.get()); + assert_eq!(actual, expected); + measured.push((len, visits)); + } + // Record both sizes before asserting the work bound, including on the old algorithm. + eprintln!("interpolation lookahead visits (slots, visits): {measured:?}"); + for (len, visits) in measured { + assert!(visits > 0 && visits <= 2 * len, "{len} slots required {visits} lookahead visits"); + } + } + + #[test] + fn series_completes_a_long_gap_at_the_missing_point_limit() { + let len = 2_000; + let gap = 700..1_300; // Exactly 30% of the completed timeline. + let mut times: Vec = (0..len).filter(|i| !gap.contains(i)).map(|i| (i + 1) as f64).collect(); + let mut values: Vec = times.iter().map(|t| 3.0 * t + 2.0).collect(); + let options = Options::default(); + let completed = series(&col(&values), &col(×), &options, DateSystem::D1900).unwrap(); + let expected: Vec = (0..len).map(|i| (3 * i + 5) as f64).collect(); + assert_eq!(completed.y, expected); + assert_eq!(completed.step, 1.0); + assert_eq!(completed.horizon((len + 1) as f64), Ok(1.0)); + + let zeroes = series(&col(&values), &col(×), &Options { interpolate: false, ..options }, DateSystem::D1900).unwrap(); + for (i, &value) in zeroes.y.iter().enumerate() { + assert_eq!(value, if gap.contains(&i) { 0.0 } else { expected[i] }); + } + + // Remove the first known point after the gap without changing either timeline endpoint. + times.remove(gap.start); + values.remove(gap.start); + assert!(matches!(series(&col(&values), &col(×), &options, DateSystem::D1900), Err(CellError::Num))); + } + fn range(xs: &[f64]) -> Arg { rf(col(xs)) } diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index 2758246ce..af28326e7 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -1,9 +1,9 @@ //! Financial functions: time value of money, cash-flow analysis, depreciation and securities. //! //! Sign convention follows Excel: money paid out is negative, money received is positive. -//! Date arguments are serial numbers (truncated) in the 1900 date system. +//! Date arguments are serial numbers (truncated) in the workbook's date system. -use gridcraft_core::date::{datetime_from_serial, days_in_month, serial_from_ymd}; +use gridcraft_core::date::{datetime_from_serial, days_in_month, is_leap, max_serial, serial_from_ymd}; use gridcraft_core::{CellError, DateSystem, Value}; use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num, round_half_away, text, to_sig_digits}; @@ -526,13 +526,12 @@ fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R { fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); - let _ = c; if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased { return Err(CellError::Num); } let full = cost * rate; let limit = cost - salvage; - let dep0 = (full * year_frac(purchased, first, basis)?).min(limit); + let dep0 = (full * year_frac(c.date_system(), purchased, first, basis)?).min(limit); if period == 0.0 { return num_val(dep0); } @@ -543,7 +542,6 @@ fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); - let _ = c; if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 { return Err(CellError::Num); } @@ -559,7 +557,7 @@ fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { }; let r = rate * coef; let mut book = cost; - let mut dep = (year_frac(purchased, first, basis)? * r * cost).round(); + let mut dep = (year_frac(c.date_system(), purchased, first, basis)? * r * cost).round(); if period == 0.0 { return num_val(dep); } @@ -592,13 +590,13 @@ fn date(a: &[Arg], i: usize) -> R { Ok(d) } -fn ymd(s: f64) -> R<(i32, u32, u32)> { - let dt = datetime_from_serial(DateSystem::D1900, s.trunc()).ok_or(CellError::Num)?; +fn ymd(sys: DateSystem, s: f64) -> R<(i32, u32, u32)> { + let dt = datetime_from_serial(sys, s.trunc()).ok_or(CellError::Num)?; Ok((dt.year, dt.month, dt.day.max(1))) } -fn serial(y: i32, m: u32, d: u32) -> R { - serial_from_ymd(DateSystem::D1900, y as i64, m as i64, d as i64).ok_or(CellError::Num) +fn serial(sys: DateSystem, y: i32, m: u32, d: u32) -> R { + serial_from_ymd(sys, y as i64, m as i64, d as i64).ok_or(CellError::Num) } fn basis_arg(a: &[Arg], i: usize) -> R { @@ -617,14 +615,10 @@ fn freq_arg(a: &[Arg], i: usize) -> R { Ok(f) } -fn is_leap(y: i32) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 -} - /// 30/360 day count (US/NASD when `us`, European otherwise). -fn days360(s1: f64, s2: f64, us: bool) -> R { - let (y1, m1, mut d1) = ymd(s1)?; - let (y2, m2, mut d2) = ymd(s2)?; +fn days360(sys: DateSystem, s1: f64, s2: f64, us: bool) -> R { + let (y1, m1, mut d1) = ymd(sys, s1)?; + let (y2, m2, mut d2) = ymd(sys, s2)?; if us { let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2); let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2); @@ -648,25 +642,25 @@ fn days360(s1: f64, s2: f64, us: bool) -> R { } /// Days between two dates under a basis. -fn day_count(s1: f64, s2: f64, basis: u32) -> R { +fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { match basis { - 0 => days360(s1, s2, true), - 4 => days360(s1, s2, false), + 0 => days360(sys, s1, s2, true), + 4 => days360(sys, s1, s2, false), _ => Ok(s2 - s1), } } /// Year fraction between two dates under a basis (YEARFRAC semantics). -fn year_frac(s1: f64, s2: f64, basis: u32) -> R { +fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) }; match basis { - 0 => Ok(days360(s1, s2, true)? / 360.0), + 0 => Ok(days360(sys, s1, s2, true)? / 360.0), 2 => Ok((s2 - s1) / 360.0), 3 => Ok((s2 - s1) / 365.0), - 4 => Ok(days360(s1, s2, false)? / 360.0), + 4 => Ok(days360(sys, s1, s2, false)? / 360.0), _ => { - let (y1, m1, d1) = ymd(s1)?; - let (y2, m2, d2) = ymd(s2)?; + let (y1, m1, d1) = ymd(sys, s1)?; + let (y2, m2, d2) = ymd(sys, s2)?; let days = s2 - s1; if y1 == y2 { return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 }); @@ -678,8 +672,8 @@ fn year_frac(s1: f64, s2: f64, basis: u32) -> R { let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 }; return Ok(days / denom); } - let start = serial(y1, 1, 1)?; - let end = serial(y2 + 1, 1, 1).unwrap_or(2_958_466.0); + let start = serial(sys, y1, 1, 1)?; + let end = serial(sys, y2 + 1, 1, 1).unwrap_or(max_serial(sys) as f64 + 1.0); let avg = (end - start) / (y2 - y1 + 1) as f64; Ok(days / avg) } @@ -687,12 +681,12 @@ fn year_frac(s1: f64, s2: f64, basis: u32) -> R { } /// Days in a year for discount securities. -fn year_basis(basis: u32, s: f64) -> R { +fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R { Ok(match basis { 0 | 2 | 4 => 360.0, 3 => 365.0, _ => { - if is_leap(ymd(s)?.0) { + if is_leap(ymd(sys, s)?.0) { 366.0 } else { 365.0 @@ -702,7 +696,7 @@ fn year_basis(basis: u32, s: f64) -> R { } /// Date `months` months before/after an anchor date, keeping end-of-month alignment. -fn shift_months(anchor: (i32, u32, u32), months: i64, eom: bool) -> R { +fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R { let (y, m, d) = anchor; let total = y as i64 * 12 + (m as i64 - 1) + months; let ny = total.div_euclid(12); @@ -712,7 +706,7 @@ fn shift_months(anchor: (i32, u32, u32), months: i64, eom: bool) -> R { } let dim = days_in_month(ny as i32, nm); let nd = if eom { dim } else { d.min(dim) }; - serial(ny as i32, nm, nd) + serial(sys, ny as i32, nm, nd) } struct Coupon { @@ -721,27 +715,27 @@ struct Coupon { num: f64, } -fn coupons(settle: f64, mat: f64, freq: f64) -> R { +fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R { if settle >= mat { return Err(CellError::Num); } - let anchor = ymd(mat)?; + let anchor = ymd(sys, mat)?; let eom = anchor.2 == days_in_month(anchor.0, anchor.1); let step = (12.0 / freq) as i64; // Estimate the count from the span, then adjust. let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1); - while k > 1 && shift_months(anchor, -(k - 1) * step, eom)? <= settle { + while k > 1 && shift_months(sys, anchor, -(k - 1) * step, eom)? <= settle { k -= 1; } let mut guard = 0; - while shift_months(anchor, -k * step, eom)? > settle { + while shift_months(sys, anchor, -k * step, eom)? > settle { k += 1; guard += 1; if guard > 100_000 { return Err(CellError::Num); } } - Ok(Coupon { pcd: shift_months(anchor, -k * step, eom)?, ncd: shift_months(anchor, -(k - 1) * step, eom)?, num: k as f64 }) + Ok(Coupon { pcd: shift_months(sys, anchor, -k * step, eom)?, ncd: shift_months(sys, anchor, -(k - 1) * step, eom)?, num: k as f64 }) } fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { @@ -752,56 +746,57 @@ fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { } } -fn coup_daybs(c: &Coupon, settle: f64, basis: u32) -> R { - day_count(c.pcd, settle, basis) +fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R { + day_count(sys, c.pcd, settle, basis) } -fn coup_daysnc(c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { +fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { match basis { - 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(c, settle, basis)?), + 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(sys, c, settle, basis)?), _ => Ok(c.ncd - settle), } } /// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions. -fn coup_args(a: &[Arg]) -> R<(f64, f64, f64, u32, Coupon)> { +fn coup_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, u32, Coupon)> { let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?); - let c = coupons(s, m, f)?; + let c = coupons(sys, s, m, f)?; Ok((s, m, f, b, c)) } -fn coupdaybs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (s, _, _, b, c) = coup_args(a)?; - num_val(coup_daybs(&c, s, b)?) +fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (s, _, _, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_daybs(cx.date_system(), &c, s, b)?) } -fn coupdays(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (_, _, f, b, c) = coup_args(a)?; +fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (_, _, f, b, c) = coup_args(a, cx.date_system())?; num_val(coup_days(&c, f, b)) } -fn coupdaysnc(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (s, _, f, b, c) = coup_args(a)?; - num_val(coup_daysnc(&c, s, f, b)?) +fn coupdaysnc(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (s, _, f, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_daysnc(cx.date_system(), &c, s, f, b)?) } -fn coupncd(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(coup_args(a)?.4.ncd) +fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.ncd) } -fn couppcd(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(coup_args(a)?.4.pcd) +fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.pcd) } -fn coupnum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(coup_args(a)?.4.num) +fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.num) } // --------------------------------------------------------------------------------------------- // Securities // --------------------------------------------------------------------------------------------- -fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); let par = if has(a, 4) { num(a, 4)? } else { 1000.0 }; let freq = freq_arg(a, 5)?; @@ -810,10 +805,10 @@ fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R { if issue >= settle || rate <= 0.0 || par <= 0.0 { return Err(CellError::Num); } - let anchor = ymd(first)?; + let anchor = ymd(sys, first)?; let eom = anchor.2 == days_in_month(anchor.0, anchor.1); let step = (12.0 / freq) as i64; - let cd = |j: i64| shift_months(anchor, j * step, eom); + let cd = |j: i64| shift_months(sys, anchor, j * step, eom); // Coupon index of the period containing a date: cd(j) <= date < cd(j+1). let index_of = |d: f64| -> R { let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000); @@ -851,7 +846,7 @@ fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R { 3 => 365.0 / freq, _ => 360.0 / freq, }; - sum += day_count(from, to, basis)? / nl; + sum += day_count(sys, from, to, basis)? / nl; } j += 1; guard += 1; @@ -862,14 +857,14 @@ fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(par * rate / freq * sum) } -fn accrintm(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R { let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; let basis = basis_arg(a, 4)?; if issue >= settle || rate <= 0.0 || par <= 0.0 { return Err(CellError::Num); } - num_val(par * rate * year_frac(issue, settle, basis)?) + num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?) } /// settlement, maturity, value, value, [basis] for discount securities. @@ -881,55 +876,55 @@ fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> { Ok((s, m, x, y, b)) } -fn disc_frac(s: f64, m: f64, b: u32) -> R { - Ok(day_count(s, m, b)? / year_basis(b, s)?) +fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R { + Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?) } -fn disc(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn disc(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, pr, red, b) = disc_args(a)?; - num_val((1.0 - pr / red) / disc_frac(s, m, b)?) + num_val((1.0 - pr / red) / disc_frac(c.date_system(), s, m, b)?) } -fn pricedisc(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, d, red, b) = disc_args(a)?; - num_val(red - d * red * disc_frac(s, m, b)?) + num_val(red - d * red * disc_frac(c.date_system(), s, m, b)?) } -fn yielddisc(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, pr, red, b) = disc_args(a)?; - num_val((red / pr - 1.0) / disc_frac(s, m, b)?) + num_val((red / pr - 1.0) / disc_frac(c.date_system(), s, m, b)?) } -fn intrate(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, inv, red, b) = disc_args(a)?; - num_val((red - inv) / inv / disc_frac(s, m, b)?) + num_val((red - inv) / inv / disc_frac(c.date_system(), s, m, b)?) } -fn received(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn received(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, inv, d, b) = disc_args(a)?; - let denom = 1.0 - d * disc_frac(s, m, b)?; + let denom = 1.0 - d * disc_frac(c.date_system(), s, m, b)?; if denom <= 0.0 { return Err(CellError::Num); } num_val(inv / denom) } -fn tbill_args(a: &[Arg]) -> R<(f64, f64)> { +fn tbill_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64)> { let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); if s >= m || x <= 0.0 { return Err(CellError::Num); } // At most one year after settlement. - let (y, mo, d) = ymd(s)?; - let limit = shift_months((y, mo, d), 12, false)?; + let (y, mo, d) = ymd(sys, s)?; + let limit = shift_months(sys, (y, mo, d), 12, false)?; if m > limit { return Err(CellError::Num); } Ok((m - s, x)) } -fn tbillprice(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (dsm, d) = tbill_args(a)?; +fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a, c.date_system())?; let p = 100.0 * (1.0 - d * dsm / 360.0); if p <= 0.0 { return Err(CellError::Num); @@ -937,13 +932,13 @@ fn tbillprice(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(p) } -fn tbillyield(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (dsm, pr) = tbill_args(a)?; +fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, pr) = tbill_args(a, c.date_system())?; num_val((100.0 - pr) / pr * 360.0 / dsm) } -fn tbilleq(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (dsm, d) = tbill_args(a)?; +fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a, c.date_system())?; if dsm <= 182.0 { return num_val(365.0 * d / (360.0 - d * dsm)); } @@ -960,24 +955,24 @@ fn tbilleq(a: &[Arg], _c: &mut dyn Ctx) -> R { } /// settlement, maturity, issue, rate, value, [basis]. -fn mat_terms(a: &[Arg]) -> R<(f64, f64, f64, f64, f64)> { +fn mat_terms(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, f64, f64)> { let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?); if s >= m || issue >= s || rate < 0.0 || x < 0.0 { return Err(CellError::Num); } - let dim = year_frac(issue, m, b)?; - let dsm = year_frac(s, m, b)?; - let ai = year_frac(issue, s, b)?; + let dim = year_frac(sys, issue, m, b)?; + let dsm = year_frac(sys, s, m, b)?; + let ai = year_frac(sys, issue, s, b)?; Ok((dim, dsm, ai, rate, x)) } -fn pricemat(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (dim, dsm, ai, rate, yld) = mat_terms(a)?; +fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, yld) = mat_terms(a, c.date_system())?; num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0) } -fn yieldmat(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (dim, dsm, ai, rate, pr) = mat_terms(a)?; +fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, pr) = mat_terms(a, c.date_system())?; if pr <= 0.0 || dsm == 0.0 { return Err(CellError::Num); } @@ -993,11 +988,11 @@ struct Bond { freq: f64, } -fn bond(settle: f64, mat: f64, freq: f64, basis: u32) -> R { - let c = coupons(settle, mat, freq)?; +fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R { + let c = coupons(sys, settle, mat, freq)?; let e = coup_days(&c, freq, basis); - let a = coup_daybs(&c, settle, basis)?; - let dsc = coup_daysnc(&c, settle, freq, basis)?; + let a = coup_daybs(sys, &c, settle, basis)?; + let dsc = coup_daysnc(sys, &c, settle, freq, basis)?; if e <= 0.0 { return Err(CellError::Num); } @@ -1022,23 +1017,23 @@ fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 { sum - accrued } -fn price(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn price(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); if rate < 0.0 || yld < 0.0 || red <= 0.0 { return Err(CellError::Num); } - let b = bond(s, m, f, basis)?; + let b = bond(c.date_system(), s, m, f, basis)?; num_val(bond_price(&b, rate, yld, red)) } -fn yield_(a: &[Arg], _c: &mut dyn Ctx) -> R { +fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R { let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); if rate < 0.0 || pr <= 0.0 || red <= 0.0 { return Err(CellError::Num); } - let b = bond(s, m, f, basis)?; + let b = bond(c.date_system(), s, m, f, basis)?; if b.n == 1.0 { let base = pr / 100.0 + b.a / b.e * rate / f; return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc); @@ -1065,13 +1060,13 @@ fn yield_(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(0.5 * (lo + hi)) } -fn duration_calc(a: &[Arg]) -> R<(f64, f64, f64)> { +fn duration_calc(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64)> { let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?); let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?); if coupon < 0.0 || yld < 0.0 { return Err(CellError::Num); } - let b = bond(s, m, f, basis)?; + let b = bond(sys, s, m, f, basis)?; let c = coupon * 100.0 / f; let y = 1.0 + yld / f; let frac = b.dsc / b.e; @@ -1091,12 +1086,12 @@ fn duration_calc(a: &[Arg]) -> R<(f64, f64, f64)> { Ok((wsum / psum / f, yld, f)) } -fn duration(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(duration_calc(a)?.0) +fn duration(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(duration_calc(a, c.date_system())?.0) } -fn mduration(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (d, y, f) = duration_calc(a)?; +fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R { + let (d, y, f) = duration_calc(a, c.date_system())?; num_val(d / (1.0 + y / f)) } @@ -1122,8 +1117,8 @@ fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 { } /// Days of `[from, to]` under a basis, or 0 when the span is empty. -fn span(from: f64, to: f64, basis: u32) -> R { - if to > from { day_count(from, to, basis) } else { Ok(0.0) } +fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R { + if to > from { day_count(sys, from, to, basis) } else { Ok(0.0) } } /// The odd-first-period terms that ODDFPRICE and ODDFYIELD share. @@ -1141,14 +1136,14 @@ struct OddFirst { freq: f64, } -fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R { +fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R { if !(mat > first && first > settle && settle > issue) { return Err(CellError::Num); } - let anchor = ymd(first)?; + let anchor = ymd(sys, first)?; let eom = anchor.2 == days_in_month(anchor.0, anchor.1); let step = (12.0 / freq) as i64; - let quasi = |k: i64| shift_months(anchor, -k * step, eom); + let quasi = |k: i64| shift_months(sys, anchor, -k * step, eom); let (mut dc, mut acc) = (0.0, 0.0); let (mut nq, mut frac) = (None, 0.0); // Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k). @@ -1163,11 +1158,11 @@ fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u3 return Err(CellError::Num); } let from = start.max(issue); - dc += span(from, end, basis)? / nl; - acc += span(from, settle.min(end), basis)? / nl; + dc += span(sys, from, end, basis)? / nl; + acc += span(sys, from, settle.min(end), basis)? / nl; if start <= settle && settle < end { nq = Some(k as f64); - frac = span(settle, end, basis)? / nl; + frac = span(sys, settle, end, basis)? / nl; } if start <= issue { break; @@ -1175,7 +1170,7 @@ fn odd_first(settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u3 k += 1; } let nq = nq.ok_or(CellError::Num)?; - let n = coupons(first, mat, freq)?.num; + let n = coupons(sys, first, mat, freq)?.num; Ok(OddFirst { n, nq, frac, dc, acc, freq }) } @@ -1193,26 +1188,26 @@ fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 { } /// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis]. -fn odd_first_args(a: &[Arg]) -> R<(OddFirst, f64, f64, f64)> { +fn odd_first_args(a: &[Arg], sys: DateSystem) -> R<(OddFirst, f64, f64, f64)> { let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?); let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?); let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?); if rate < 0.0 || red <= 0.0 { return Err(CellError::Num); } - Ok((odd_first(s, m, issue, first, f, basis)?, rate, x, red)) + Ok((odd_first(sys, s, m, issue, first, f, basis)?, rate, x, red)) } -fn oddfprice(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (t, rate, yld, red) = odd_first_args(a)?; +fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let (t, rate, yld, red) = odd_first_args(a, c.date_system())?; if yld < 0.0 { return Err(CellError::Num); } num_val(odd_first_price(&t, rate, yld, red)) } -fn oddfyield(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (t, rate, pr, red) = odd_first_args(a)?; +fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let (t, rate, pr, red) = odd_first_args(a, c.date_system())?; if pr <= 0.0 { return Err(CellError::Num); } @@ -1240,11 +1235,11 @@ fn oddfyield(a: &[Arg], _c: &mut dyn Ctx) -> R { } /// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period. -fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> { +fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> { if !(mat > settle && settle > last) { return Err(CellError::Num); } - let anchor = ymd(last)?; + let anchor = ymd(sys, last)?; let eom = anchor.2 == days_in_month(anchor.0, anchor.1); let step = (12.0 / freq) as i64; let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0); @@ -1255,15 +1250,15 @@ fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, if k > MAX_QUASI { return Err(CellError::Num); } - let end = shift_months(anchor, k * step, eom)?; + let end = shift_months(sys, anchor, k * step, eom)?; let nl = normal_length(start, end, freq, basis); if nl <= 0.0 { return Err(CellError::Num); } let stop = end.min(mat); - dc += span(start, stop, basis)? / nl; - acc += span(start, settle.min(stop), basis)? / nl; - dsc += span(settle.max(start), stop, basis)? / nl; + dc += span(sys, start, stop, basis)? / nl; + acc += span(sys, start, settle.min(stop), basis)? / nl; + dsc += span(sys, settle.max(start), stop, basis)? / nl; if end >= mat { break; } @@ -1274,18 +1269,18 @@ fn odd_last(settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, } /// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis]. -fn odd_last_args(a: &[Arg]) -> R<((f64, f64, f64), f64, f64, f64, f64)> { +fn odd_last_args(a: &[Arg], sys: DateSystem) -> R<((f64, f64, f64), f64, f64, f64, f64)> { let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?); let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?); let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?); if rate < 0.0 || red <= 0.0 { return Err(CellError::Num); } - Ok((odd_last(s, m, last, f, basis)?, rate, x, red, f)) + Ok((odd_last(sys, s, m, last, f, basis)?, rate, x, red, f)) } -fn oddlprice(a: &[Arg], _c: &mut dyn Ctx) -> R { - let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a)?; +fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a, c.date_system())?; if yld < 0.0 { return Err(CellError::Num); } @@ -1293,8 +1288,8 @@ fn oddlprice(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon) } -fn oddlyield(a: &[Arg], _c: &mut dyn Ctx) -> R { - let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a)?; +fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a, c.date_system())?; if pr <= 0.0 { return Err(CellError::Num); } @@ -2019,4 +2014,31 @@ mod tests { is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); } + + #[test] + fn securities_read_dates_in_the_1904_system() { + let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); + let ev4 = |name: &str, args: Vec| { + let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; + crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) + }; + let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); + close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); + close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); + close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); + let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; + close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); + // Results that already matched Excel keep matching. + let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); + close(semi("COUPPCD"), 39050.0); + close(semi("COUPDAYS"), 180.0); + close(semi("COUPDAYBS"), 105.0); + close(semi("COUPDAYSNC"), 75.0); + close(semi("COUPNUM"), 2.0); + let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; + close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); + close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); + let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; + close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); + } } diff --git a/crates/functions/src/info.rs b/crates/functions/src/info.rs index 84ca72a48..7aa2c2660 100644 --- a/crates/functions/src/info.rs +++ b/crates/functions/src/info.rs @@ -89,7 +89,17 @@ fn na(_a: &[Arg], _c: &mut dyn Ctx) -> R { } fn n_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { - match first(a) { + // N(range) reads the range's first cell, but a computed array (N(A1:A3>0), N({...})) is + // converted element by element, so N can stand in for `--` inside SUMPRODUCT. + if let Some(Arg { value: Value::Array(arr), from_ref: false }) = a.first() { + let data = arr.data.iter().map(|v| n_of(v.clone()).unwrap_or_else(Value::Error)).collect(); + return Array::new(arr.rows, arr.cols, data).map(Value::from).ok_or(CellError::Calc); + } + n_of(first(a)) +} + +fn n_of(v: Value) -> R { + match v { Value::Number(n) => Ok(Value::Number(n)), Value::Bool(b) => Ok(Value::Number(if b { 1.0 } else { 0.0 })), Value::Error(e) => Err(e), @@ -536,7 +546,9 @@ mod tests { close(ev("N", vec![b(true)]), 1.0); close(ev("N", vec![t("7")]), 0.0); is_err(ev("N", vec![e(CellError::Num)]), CellError::Num); - close(ev("N", vec![av(row(&[3.0, 4.0]))]), 3.0); + close(ev("N", vec![rf(row(&[3.0, 4.0]))]), 3.0); + let lifted = ev("N", vec![av(arr(vec![vec![Value::Bool(true), tv("x")], vec![Value::Error(CellError::Div0), nv(4.0)]]))]); + assert_eq!(rows_of(&lifted), vec![vec![nv(1.0), nv(0.0)], vec![Value::Error(CellError::Div0), nv(4.0)]]); is_text(ev("INFO", vec![t("RECALC")]), "Automatic"); is_err(ev("INFO", vec![t("bogus")]), CellError::Value); } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 148d68221..d7ccc8726 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -111,6 +111,15 @@ pub trait Ctx { fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option { None } + /// For GROUPBY/PIVOTBY: whether their function argument is a LAMBDA (its value is then not used). + fn has_lambda(&self) -> bool { + false + } + /// Calls that LAMBDA with `arg` (one group's values) and returns its single result; `None` when + /// there is no lambda. + fn call_lambda(&mut self, _arg: Value) -> Option { + None + } } pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value; diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 9d8642e24..861c4768e 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -534,6 +534,25 @@ fn strict_num(v: &Value) -> R { } } +/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a +/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated +/// gigabytes and could abort the process. +const MAX_REGRESSORS: usize = 255; +/// Most multiply-adds (rows × k²) a regression may spend building its normal equations. +const MAX_REGRESSION_WORK: usize = 1_000_000_000; + +/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`, +/// more than [`MAX_REGRESSION_WORK`] multiply-adds is `#NUM!`. +fn check_regression_size(n: usize, k: usize) -> R<()> { + if k > MAX_REGRESSORS { + return Err(CellError::Value); + } + match n.checked_mul(k).and_then(|nk| nk.checked_mul(k)) { + Some(work) if work <= MAX_REGRESSION_WORK => Ok(()), + _ => Err(CellError::Num), + } +} + fn parse_xy(y: &Value, x: Option<&Value>) -> R { let ya = as_array(y); let n = ya.data.len(); @@ -546,12 +565,14 @@ fn parse_xy(y: &Value, x: Option<&Value>) -> R { if xa.rows == ya.rows && xa.cols == ya.cols { (xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::>>()?, 1) } else if !y_is_row && xa.rows == n { + check_regression_size(n, xa.cols)?; let mut rows = Vec::with_capacity(n); for r in 0..n { rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); } (rows, xa.cols) } else if y_is_row && xa.cols == n { + check_regression_size(n, xa.rows)?; let mut rows = Vec::with_capacity(n); for c in 0..n { rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); @@ -638,6 +659,7 @@ fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { if n == 0 || x.len() != n { return Err(CellError::Value); } + check_regression_size(n, k)?; let nf = n as f64; let (xm, ym) = if constant { let xm: Vec = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::() / nf).collect(); @@ -1243,6 +1265,10 @@ fn binom_inv(a: &[Arg], _: C) -> R { } while hi - lo > 1.0 { let mid = ((lo + hi) / 2.0).floor(); + // Above 2^53 adjacent f64 values are more than 1 apart, so the gap can stop shrinking. + if mid <= lo || mid >= hi { + break; + } if binom_cdf(mid, n, p) >= alpha { hi = mid; } else { @@ -2245,7 +2271,7 @@ pub(crate) fn specs() -> Vec { #[cfg(test)] mod tests { - use super::{aggregate_values, aggregate_values_k}; + use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; use crate::util::testutil::*; use gridcraft_core::{CellError, Value}; @@ -2531,6 +2557,28 @@ mod tests { is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); } + // #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) + // inversion; a wider TOROW aborted the process. It is now refused before allocating. + #[test] + fn linest_family_caps_regressor_count() { + let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); + let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); + for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { + // y is one cell, so every column of x is a predictor. + is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); + // y is a row, so every row of x is a predictor. + is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); + } + is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); + // At the cap the fit still runs (one observation: coefficients are zero, not an error). + assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); + // The rows × k² work bound. + assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); + assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); + assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); + assert_eq!(check_regression_size(10_000_000, 1), Ok(())); + } + #[test] fn frequency_prob() { let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); @@ -2624,6 +2672,24 @@ mod tests { is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); } + #[test] + fn binom_inv_huge_trials_returns() { + // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. + let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; + for (name, trials, p, alpha) in cases { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = ev(name, vec![n(trials), n(p), n(alpha)]); + let _ = tx.send(r); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} + Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), + Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), + } + } + } + #[test] fn discrete() { close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 621c9b53b..71a6ccb4b 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -409,10 +409,11 @@ const THAI_DIGITS: [&str; 10] = ["ศูนย์", "หนึ่ง", "สอ const THAI_PLACES: [&str; 6] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน"]; /// Thai words for a group of up to six decimal digits (most significant first); empty for zero. -/// One in the tens is สิบ, two is ยี่สิบ, and a final one after higher digits is เอ็ด. -fn thai_group(digits: &[u8]) -> String { +/// One in the tens is สิบ, two is ยี่สิบ, and a final one after higher digits is เอ็ด; `after` +/// says the number already has a non-zero digit in a higher group (so 1,000,001 ends in เอ็ด). +fn thai_group(digits: &[u8], after: bool) -> String { let n = digits.len(); - let higher = digits.iter().take(n.saturating_sub(1)).any(|d| *d != 0); + let higher = after || digits.iter().take(n.saturating_sub(1)).any(|d| *d != 0); let mut out = String::new(); for (i, &d) in digits.iter().enumerate() { let place = n - 1 - i; @@ -445,7 +446,7 @@ fn thai_number(digits: &str) -> String { let mut start = 0; let mut len = first; while let Some(group) = d.get(start..start + len) { - out.push_str(&thai_group(group)); + out.push_str(&thai_group(group, start > 0)); start += len; len = 6; if start < d.len() { @@ -976,6 +977,17 @@ mod tests { is_text(bt(1.999), "สองบาทถ้วน"); is_text(bt(0.005), "หนึ่งสตางค์"); is_text(bt(-0.001), "ศูนย์บาทถ้วน"); + // A final one is เอ็ด after higher digits in any group, millions included (#1234). + is_text(bt(21_000_001.0), "ยี่สิบเอ็ดล้านเอ็ดบาทถ้วน"); + is_text(bt(1_000_001.0), "หนึ่งล้านเอ็ดบาทถ้วน"); + is_text(bt(1_000_001_000_000.0), "หนึ่งล้านเอ็ดล้านบาทถ้วน"); + is_text(bt(1001.0), "หนึ่งพันเอ็ดบาทถ้วน"); + is_text(bt(11.0), "สิบเอ็ดบาทถ้วน"); + is_text(bt(1.0), "หนึ่งบาทถ้วน"); + is_text(bt(0.01), "หนึ่งสตางค์"); + is_text(bt(0.21), "ยี่สิบเอ็ดสตางค์"); + is_text(bt(1250.5), "หนึ่งพันสองร้อยห้าสิบบาทห้าสิบสตางค์"); + is_text(bt(0.25), "ยี่สิบห้าสตางค์"); is_err(ev("BAHTTEXT", vec![t("abc")]), CellError::Value); // Huge numbers give text (or #NUM! where rounding overflows), never a panic. assert!(matches!(bt(-1e300), Value::Text(_))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 650a1caac..3a1ecb1fa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1879,7 +1879,12 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); ui.horizontal(|ui| { ui.label("Find what:"); - ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)); + // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. + if !d.values.contains_key("focused") { + r.request_focus(); + d.values.insert("focused".into(), json!(true)); + } }); if d.tab == "Replace" { ui.horizontal(|ui| { diff --git a/crates/ui-egui/tests/find_dialog_focus.rs b/crates/ui-egui/tests/find_dialog_focus.rs new file mode 100644 index 000000000..08cee3fc8 --- /dev/null +++ b/crates/ui-egui/tests/find_dialog_focus.rs @@ -0,0 +1,48 @@ +//! Opening Find and Replace puts the cursor in "Find what", so the user can type at once. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl on Windows and Linux sets both `ctrl` and `command`. +const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(6); + h +} + +fn type_after_shortcut(k: Key) -> Option { + let mut h = harness(); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: CTRL }); + h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: CTRL }); + h.run_steps(6); + h.input_mut().events.push(Event::Text("abc".into())); + h.run_steps(6); + let d = h.state().dialog.as_ref().expect("find dialog is open"); + assert_eq!(d.name, "find"); + d.values.get("what").cloned() +} + +#[test] +fn typing_right_after_ctrl_f_fills_find_what() { + assert_eq!(type_after_shortcut(Key::F), Some(json!("abc"))); +} + +#[test] +fn typing_right_after_ctrl_h_fills_find_what() { + assert_eq!(type_after_shortcut(Key::H), Some(json!("abc"))); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 3e419924c..b3cdf9f3c 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (ADDRESS quotes sheet names like Excel (#1458); MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-21 · **Change:** minor (SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -55,6 +55,11 @@ By the Excel version that introduced them (working / total): before 2010 346 / 3 Dynamic-array and LAMBDA-era functions (FILTER, SORT, UNIQUE, XLOOKUP, VSTACK, TAKE, GROUPBY, PIVOTBY, REGEXTEST…) are all present. +FORECAST.ETS missing-point interpolation walks known points once, so filling a long gap takes +linear work in the completed timeline length (#1042). Interpolation values, nearest-value +endpoint handling, zero-fill mode and the existing 30% missing-point limit are unchanged. +This improves series preparation; it does not change the model-fitting or seasonality algorithms. + ### Scope of what's missing | Function(s) | In scope? | Estimate | Note | @@ -77,6 +82,7 @@ PIVOTBY, REGEXTEST…) are all present. | Defined names (workbook and sheet scope), Create from Selection | done | | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) | | Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) | +| SUBTOTAL / AGGREGATE input size | references trim to the used range; all areas, arrays and values of one call are charged against the 16-million-cell limit before any cell is read (skipped hidden, nested and error cells count), and `#NUM!` past it (#2175) | | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | | Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done | @@ -115,8 +121,10 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | SUBTOTAL and AGGREGATE charge every argument against the evaluator's 16-million-cell limit before scanning; a value in XFD1048576 no longer makes `SUBTOTAL(9,A:XFD)` read the whole sheet (#2175) | | 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) | | 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands | +| 2026-10-16 | minor | FORECAST.ETS gap interpolation uses a forward-only known-point scan instead of rescanning each gap for every missing value; numerical behavior and missing-point limits are preserved (#1042) | | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-15 | minor | ADDRESS quotes a sheet name the way Excel does: names that read as an A1 cell (`A1`) or an R1C1 reference (`R1C1`, `RC`) and names starting with `.` get quotes; `TRUE` / `FALSE` stay bare (#1458) | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |