Integration: track t07

# Conflicts:
#	crates/calc/src/tests.rs
#	docs/function-parity.md
This commit is contained in:
aiongg
2026-10-22 03:03:23 +00:00
13 changed files with 754 additions and 233 deletions
+126 -40
View File
@@ -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<Area>,
/// The LAMBDA given as the function argument of GROUPBY/PIVOTBY.
lambda: Option<Arc<Lambda>>,
}
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<Value> {
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<Arc<Array>> = 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<usize> {
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<Value> {
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))
}
+132
View File
@@ -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"));
}
+60 -38
View File
@@ -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<f64> {
let n = num(a, i)?;
fn serial_arg(a: &[Arg], i: usize, sys: DateSystem) -> R<f64> {
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<i64> {
Ok(serial_arg(a, i)?.floor() as i64)
fn day_arg(a: &[Arg], i: usize, sys: DateSystem) -> R<i64> {
Ok(serial_arg(a, i, sys)?.floor() as i64)
}
fn dt(sys: DateSystem, serial: f64) -> R<DateTime> {
@@ -66,8 +66,9 @@ fn time(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
fn part(a: &[Arg], c: &mut dyn Ctx, f: fn(&DateTime) -> f64) -> R<Value> {
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<Value> {
@@ -81,20 +82,20 @@ fn day(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
}
/// Seconds into the day, rounded to the nearest second.
fn day_seconds(a: &[Arg]) -> R<i64> {
let s = serial_arg(a, 0)?;
fn day_seconds(a: &[Arg], sys: DateSystem) -> R<i64> {
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<Value> {
num_val((day_seconds(a)? / 3600) as f64)
fn hour(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val((day_seconds(a, c.date_system())? / 3600) as f64)
}
fn minute(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val((day_seconds(a)? / 60 % 60) as f64)
fn minute(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val((day_seconds(a, c.date_system())? / 60 % 60) as f64)
}
fn second(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val((day_seconds(a)? % 60) as f64)
fn second(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val((day_seconds(a, c.date_system())? % 60) as f64)
}
fn now(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
@@ -105,9 +106,10 @@ fn today(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
}
fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<i64> {
fn weeknum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
}
fn isoweeknum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<i64> {
@@ -157,7 +160,7 @@ fn months_arg(a: &[Arg], i: usize) -> R<i64> {
fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
num_val(n - n.floor())
}
fn days(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val((day_arg(a, 0)? - day_arg(a, 1)?) as f64)
fn days(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
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<Vec<i64>> {
fn holidays(v: Option<&Arg>, sys: DateSystem) -> R<Vec<i64>> {
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<Vec<i64>> {
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<Value> {
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<Value> {
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<Value> {
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<FnSpec> {
@@ -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);
}
}
+39 -13
View File
@@ -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<Agg> {
}
}
/// 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<Option<Agg>> {
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<Agg>, 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<Option<Array>> {
if has(args, i) { Ok(Some(arr_arg(args, i)?)) } else { Ok(None) }
}
fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
}
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<Value> {
let mut agg_row = |rows: &[usize]| -> Vec<Value> {
(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<Value> {
fn pivotby(args: &[Arg], ctx: &mut dyn Ctx) -> R<Value> {
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<Value> {
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<Value>>, c: Option<&Vec<Value>>| -> Vec<usize> {
let rkey = r.map(|k| row_key(k));
@@ -873,7 +893,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
out.push(line);
}
let body_line = |r: Option<&Vec<Value>>| -> Vec<Value> {
let mut body_line = |r: Option<&Vec<Value>>| -> Vec<Value> {
let mut line: Vec<Value> = 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")]]);
+74 -1
View File
@@ -374,14 +374,21 @@ pub(crate) fn series(values: &Value, timeline: &Value, opts: &Options, sys: Date
/// value at either end).
fn interpolate(slots: &[Option<f64>]) -> Vec<f64> {
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<usize> = 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<f64> = (0..len).map(|i| (3 * i + 5) as f64).collect();
let slots: Vec<Option<f64>> = 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<f64> = (0..len).filter(|i| !gap.contains(i)).map(|i| (i + 1) as f64).collect();
let mut values: Vec<f64> = times.iter().map(|t| 3.0 * t + 2.0).collect();
let options = Options::default();
let completed = series(&col(&values), &col(&times), &options, DateSystem::D1900).unwrap();
let expected: Vec<f64> = (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(&times), &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(&times), &options, DateSystem::D1900), Err(CellError::Num)));
}
fn range(xs: &[f64]) -> Arg {
rf(col(xs))
}
+154 -132
View File
@@ -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<Value> {
fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
};
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<f64> {
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<f64> {
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<f64> {
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<u32> {
@@ -617,14 +615,10 @@ fn freq_arg(a: &[Arg], i: usize) -> R<f64> {
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<f64> {
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<f64> {
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<f64> {
}
/// Days between two dates under a basis.
fn day_count(s1: f64, s2: f64, basis: u32) -> R<f64> {
fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R<f64> {
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<f64> {
fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R<f64> {
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<f64> {
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<f64> {
}
/// Days in a year for discount securities.
fn year_basis(basis: u32, s: f64) -> R<f64> {
fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R<f64> {
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<f64> {
}
/// 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<f64> {
fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R<f64> {
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<f64> {
}
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<Coupon> {
fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R<Coupon> {
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<f64> {
day_count(c.pcd, settle, basis)
fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R<f64> {
day_count(sys, c.pcd, settle, basis)
}
fn coup_daysnc(c: &Coupon, settle: f64, freq: f64, basis: u32) -> R<f64> {
fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R<f64> {
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<Value> {
let (s, _, _, b, c) = coup_args(a)?;
num_val(coup_daybs(&c, s, b)?)
fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R<Value> {
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<Value> {
let (_, _, f, b, c) = coup_args(a)?;
fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R<Value> {
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<Value> {
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<Value> {
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<Value> {
num_val(coup_args(a)?.4.ncd)
fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(coup_args(a, c.date_system())?.4.ncd)
}
fn couppcd(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(coup_args(a)?.4.pcd)
fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(coup_args(a, c.date_system())?.4.pcd)
}
fn coupnum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(coup_args(a)?.4.num)
fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(coup_args(a, c.date_system())?.4.num)
}
// ---------------------------------------------------------------------------------------------
// Securities
// ---------------------------------------------------------------------------------------------
fn accrint(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
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<i64> {
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<Value> {
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<Value> {
num_val(par * rate / freq * sum)
}
fn accrintm(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<f64> {
Ok(day_count(s, m, b)? / year_basis(b, s)?)
fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R<f64> {
Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?)
}
fn disc(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn disc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn received(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let (dsm, d) = tbill_args(a)?;
fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
num_val(p)
}
fn tbillyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (dsm, pr) = tbill_args(a)?;
fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let (dsm, d) = tbill_args(a)?;
fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
}
/// 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<Value> {
let (dim, dsm, ai, rate, yld) = mat_terms(a)?;
fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let (dim, dsm, ai, rate, pr) = mat_terms(a)?;
fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Bond> {
let c = coupons(settle, mat, freq)?;
fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R<Bond> {
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<Value> {
fn price(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
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<Value> {
num_val(duration_calc(a)?.0)
fn duration(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(duration_calc(a, c.date_system())?.0)
}
fn mduration(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (d, y, f) = duration_calc(a)?;
fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<f64> {
if to > from { day_count(from, to, basis) } else { Ok(0.0) }
fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R<f64> {
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<OddFirst> {
fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R<OddFirst> {
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<Value> {
let (t, rate, yld, red) = odd_first_args(a)?;
fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let (t, rate, pr, red) = odd_first_args(a)?;
fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
}
/// Σ 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<Value> {
let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a)?;
fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon)
}
fn oddlyield(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a)?;
fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Arg>| {
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);
}
}
+14 -2
View File
@@ -89,7 +89,17 @@ fn na(_a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
}
fn n_fn(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
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<Value> {
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);
}
+9
View File
@@ -111,6 +111,15 @@ pub trait Ctx {
fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option<bool> {
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<Value> {
None
}
}
pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;
+67 -1
View File
@@ -534,6 +534,25 @@ fn strict_num(v: &Value) -> R<f64> {
}
}
/// 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<Xy> {
let ya = as_array(y);
let n = ya.data.len();
@@ -546,12 +565,14 @@ fn parse_xy(y: &Value, x: Option<&Value>) -> R<Xy> {
if xa.rows == ya.rows && xa.cols == ya.cols {
(xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::<R<Vec<_>>>()?, 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::<R<Vec<_>>>()?);
}
(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::<R<Vec<_>>>()?);
@@ -638,6 +659,7 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
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<f64> = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::<f64>() / nf).collect();
@@ -1243,6 +1265,10 @@ fn binom_inv(a: &[Arg], _: C) -> R<Value> {
}
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<FnSpec> {
#[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);
+16 -4
View File
@@ -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(_)));
+6 -1
View File
@@ -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| {
+48
View File
@@ -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<serde_json::Value> {
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")));
}
+9 -1
View File
@@ -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) |