Skip to content

Formula Language

Formulas are how you customize a FinZot plan. They replace static numbers with logic, so the plan can react to changing conditions over time. Anywhere a plan takes a value, such as an income or expense amount, a milestone trigger, or a Roth conversion size, you can supply a formula instead of a fixed number.

Each formula is a standalone expression that returns one of two things:

  • A number, used for any amount, such as a custom income or expense.
  • A boolean (TRUE or FALSE), used to trigger milestones or control conditional logic.

For the complete list of variables a formula can read, such as net_worth or age_primary, see the Planner Context reference.

  • Numbers. All numbers are 64-bit floats. You can write them several ways:
    • 123, a whole number
    • 1_000_000, with underscores as visual separators that FinZot ignores
    • 45.67, a decimal
    • 10%, a percentage, which becomes 0.1
    • .75%, a leading decimal, which becomes 0.0075
  • Booleans. TRUE or FALSE.
  • Variables. Fields from the planner, like invested_assets or total_debt.
  • Comments. Use //. FinZot ignores everything after it on that line.
    • invested_assets * 1.1 // Add a 10% raise
  • Case insensitivity. All variables, function names, and keywords such as IF, AND, and TRUE are case-insensitive. age_primary is the same as Age_Primary.

The language supports standard arithmetic, comparison, and logical operators.

CategoryOperatorsDescription
Grouping( ... )Controls the order of operations.
Exponent^Exponentiation. 2 ^ 3 is 8.
Arithmetic*, /Multiplication and division.
+, -Addition and subtraction.
Comparison=, !=, <>Equal, not equal.
>, >=, <, <=Greater than, less than, or equal.
LogicalNOTInverts a boolean. NOT TRUE is FALSE.
ANDTRUE if both sides are TRUE.
ORTRUE if at least one side is TRUE.

The AND and OR operators are short-circuiting. They stop evaluating as soon as they have an answer.

Dividing by zero has no answer, and FinZot treats it that way rather than stopping the projection. When a formula reaches a division whose divisor is zero, the whole formula is undefined for that year. Nothing around the division recovers a value from it: not MAX, not a comparison, not NOT. A divisor within 0.000000001 of zero counts as zero.

A power with no finite answer is undefined in the same way: 0 ^ -1 (a division by zero in disguise), a negative base with a fractional exponent such as (-8) ^ 0.5, or a result too large to represent, such as 10 ^ 400. A negative base with a whole exponent is fine: (-8) ^ 3 is -512.

What an undefined formula means where it is used:

  • Sizing an entry. The year books nothing, as if the amount were 0.
  • A milestone trigger. The milestone does not fire that year. It is asked again the next year, and fires once the condition has an answer and that answer is TRUE.
  • A named formula. A formula that reads it is undefined too, unless the read sits in a branch that is not taken.

Only a division that evaluation reaches counts. IF, CASE, AND, and OR skip the branches they do not need, so a guard works:

// Spread this year's assets over the years to 90, without assuming there is one
IF(age < 90, invested_assets / (90 - age), 0)

years_remaining never falls below 1, so it is always safe to divide by.

Operators follow standard precedence, like PEMDAS or BODMAS. ^ is evaluated first, then * and /, then + and -. AND is evaluated before OR. Parentheses override this precedence.

((taxable_balance + roth_balance) >= 1_500_000) AND (NOT debt_free)

These functions choose a value based on one or more tests.

  • IF(condition, then_value, else_value) returns then_value if the condition is TRUE, and else_value otherwise.

    // Spend 5k on vacation if over 65, otherwise 2k
    IF(age_primary >= 65, 5000, 2000)
  • IFS(condition1, value1, [condition2, value2], ...) evaluates pairs of conditions and values, and returns the value for the first condition that is TRUE. If no condition is true, it returns 0.0. Use TRUE as the final condition for a default “else” value.

    IFS(
    net_worth > 5_000_000, 0.02,
    net_worth > 1_000_000, 0.01,
    TRUE, 0.0
    )
  • CASE WHEN condition1 THEN value1 ... [ELSE else_value] END is a readable, SQL-style alternative to IFS. It returns the THEN value for the first WHEN condition met. If none match, it returns the ELSE value, or 0.0 if ELSE is omitted.

    // Set a spending goal based on the plan year
    CASE
    WHEN calendar_year <= 2040 THEN 75_000
    WHEN calendar_year <= 2045 THEN 80_000
    ELSE 85_000
    END

These are identical to the AND, OR, and NOT operators, but in function form for Excel familiarity.

  • AND(expr1, expr2, ...) is TRUE if all arguments are TRUE.
  • OR(expr1, expr2, ...) is TRUE if any argument is TRUE.
  • NOT(expr) inverts a single boolean expression.
AND(
age_primary > 50,
net_worth > 1_000_000,
debt_free
)
  • MIN(num1, num2, ...) returns the smallest number in the list.
  • MAX(num1, num2, ...) returns the largest number in the list.
    // Net savings, but never less than zero
    MAX(0, net_worth - total_debt)
    // Cap a withdrawal at 50,000
    MIN(FUTURE_DOLLARS(40000), 50000)
  • ABS(number) returns the absolute, non-negative value.
    ABS(roth_balance - traditional_balance)
  • POWER(base, exponent) raises a number to a power. It is identical to the ^ operator.
    POWER(1.03, age_primary - 20)
  • ROUND(number, digits) rounds like Excel, halves away from zero. digits is optional (default 0), a literal from -15 to 15; negative rounds left of the decimal point.
    ROUND(inflation_rate, 3)

These functions look at data from previous years. Use them to model trends, smooth volatility, or create time-based triggers.

The first argument can be any numeric expression built from planner fields, literals, arithmetic, and helper functions, such as net_worth - total_debt or BALANCE("account_id"). FinZot evaluates the whole expression as of the earlier year. A named formula cannot appear inside a time-series function. Only planner fields and helper functions keep a history. Write the expression out in place instead of naming it.

Time-series functions nest. A nested window is built from year-end closes only. LAG(ROLLING_AVG(net_worth, 3), 1) averages the closes of the three years ending last year. That is not the number ROLLING_AVG(net_worth, 3) returned last year. That number counted last year as it stood mid-year. ROLLING_MAX(LAG(net_worth, 1), 5) is the highest close of the five years before this one.

A look back and a live read are different measurements. Every look back reads a year-end close, after that year’s spending, taxes, and settlement. Every field read without a lag reads the year as it stands at the step your entry runs in (planner context). Subtracting one from the other does not give a year-over-year change. net_worth - LAG(net_worth, 1) is not this year’s savings, and it returns a different number at an expense than at a withdrawal. For the change in a balance, compare two closes with LAG(net_worth, 1) - LAG(net_worth, 2). That needs two years of plan history behind it before it means anything.

  • LAG(expression, periods) returns the value expression had at the close of the year periods back. LAG(net_worth, 0) is the exception. It returns the current year as it stands at the step your entry runs in. A lag that reaches before the plan started returns 0. A milestone trigger gets no answer from it instead, and does not fire that year. In a trigger, LAG(net_worth, 0) is last year’s close, because a trigger runs before its year (milestones and timing).

    // Last year's closing balance of one account, less what it owed
    LAG(BALANCE("account_id") - total_debt, 1)
    // What was spent last year, the base for a spending rule
    LAG(consumption, 1)
  • PCT_CHANGE(expression, periods) returns the percentage change between the current value and the value from periods ago, computed as (current - past) / past. periods must be 1 or greater. It returns 0.0 if the past value is zero or needs a year before the plan started. In a milestone trigger, one that needs a year before the plan started has no answer instead.

    // Net worth dropped more than 20% in the last year
    PCT_CHANGE(net_worth, 1) < -0.20
    // One account lost value last year
    PCT_CHANGE(BALANCE("account_id"), 1) < 0

These compute a single statistic over a window of the last periods years, including the current year.

  • ROLLING_SUM(expression, periods) returns the total over the window.
  • ROLLING_AVG(expression, periods) returns the average over the window.
  • ROLLING_MIN(expression, periods) returns the smallest value in the window.
  • ROLLING_MAX(expression, periods) returns the largest value in the window.
// Variable withdrawal rate based on the 3-year average of invested assets
CASE
WHEN ROLLING_AVG(invested_assets, 3) < 1_000_000 THEN 0.035
WHEN ROLLING_AVG(invested_assets, 3) < 3_000_000 THEN 0.040
ELSE 0.045
END

The window’s last year is not measured like the rest. A window ends with the current year as it stands at the step your entry runs in. Every earlier year in it is a year-end close. So ROLLING_AVG(net_worth, 3) averages one mid-year reading with two closes. The mid-year reading moves if you attach the rule to a different entry. Read a window as a smoothed trend, not as an average of three comparable numbers.

Rolling functions in early years: rolling functions still run in the first few years of a plan, but on a partial window. The starting balances count as one year of history, so ROLLING_AVG(net_worth, 3) sizing a withdrawal in the first plan year averages the starting net worth and that year. Nothing flowed before the plan started, so a window over income, spending, a return, or inflation counts plan years only: ROLLING_AVG(portfolio_return, 5) in the first plan year is that year’s return, not the return averaged with a zero. An expression that mixes the two, such as net_worth * portfolio_return, counts as a flow. Missing years never count against a condition: ROLLING_AND(debt_free, 3) is TRUE while every year it can see is debt-free. A window only counts the years the whole expression can be evaluated for. A lag inside the expression costs the window one year for every year of lag depth. A three-year window over an expression holding LAG(x, 1) has one element in the first plan year, not three. A window with no year in it returns 0, or TRUE for ROLLING_AND and FALSE for ROLLING_OR. In a milestone trigger it has no answer instead.

Do not hand-roll a window out of LAG. A lag that reaches before the plan started returns 0, so (portfolio_return + LAG(portfolio_return, 1) + LAG(portfolio_return, 2)) / 3 divides a one-year sum by three in the first plan year and reads a third of the true return. A guardrail such as “trailing average below 4%” written that way stays on for the first two years no matter what the market did. ROLLING_AVG(portfolio_return, 3) divides by the years it actually saw.

These look at a window of boolean values.

  • ROLLING_AND(condition, periods) is TRUE if the condition held in every year of the window.
  • ROLLING_OR(condition, periods) is TRUE if the condition held in at least one year of the window.
// Trigger if the 3-year minimum net worth dropped below 1M, or not yet debt-free
OR(
ROLLING_MIN(net_worth, 3) < 1_000_000,
NOT debt_free
)
// Go on a 5,000 vacation, but only after 3 straight debt-free years
IF(ROLLING_AND(debt_free, 3), 5000, 0)
  • Planner Context — The live variables and helper functions a formula can read, such as net worth, age, balances, income, and returns.
  • How a Year Works — The order FinZot processes each projection year. Income, expenses, and required payments first, then each list of actions you control, ending in Roth Conversions and Withdrawals. This order is also what a formula reads: it decides which of the year’s flows have happened by the time an entry is sized. Also the three stages that cover a shortfall and the default account that absorbs surplus.