Timeseries in Nodlin

Sep 12, 2026 Β· 18 min read

What is the timeseries library?

Nodlin’s timeseries module models regular economic quantities β€” revenue, debt, rates, margins β€” as first-class values on the graph. It is available in:

This page is the shared concepts and API guide. Surface-specific notes (single-line constraints, multi-line packages, Go imports) live in the linked docs.

Audience: you understand stocks, flows, rates, and income statements. You do not need to know Go to use expressions or scripts.

When to use it

Use timeseries when:

  • Quantities sit on a shared calendar (monthly, quarterly, business-day, …)
  • Economics matter: stock vs flow vs rate, currency units, day-count accrual
  • Irregular dated hits (issuances, announcements) must project onto a regular model grid
  • Changing one driver should recompute dependents with kind, unit, and timeline preserved

Prefer plain numbers, lists, or maps when you only need a scalar total or a one-off table column β€” see Expressions.


Mental model

Regular economic quantities are a Series:

DimensionMeaningExamples
TimelineWhen regular periods existQuarterly from 2025-01-01 for 12 periods
ValuesHow much each period[12.4, 13.1, …] $bn
ValidWhether each period is observed (optional)missing mid-history, not yet published
UnitIn what unitsUSD, dimensionless
KindWhat the numbers mean over timeStock, Flow, Rate, Ratio, …

Irregular quantities use an EventSeries (dated spikes). They become a Series only after project / asof onto a timeline. Timelines stay regular and half-open [start, end).

Nodlin’s graph holds the causal structure (which node depends on which). Each formula is a series calculation. Change an assumption series; dependents recompute β€” still with full kind, unit, and timeline semantics between nodes.

Kind (semantics)

KindMeaningTypical use
stockLevel at a point in timeDebt outstanding, cash balance
flowQuantity over a periodRevenue, COGS, interest expense
rateRate applying over time (usually annual)Coupon / financing rate
ratioDimensionless relationship (does not accrue)Gross margin, tax rate, LTV
indexLevel with arbitrary baseCumulative growth index
changePeriod-to-period changeGrowth after pct_change

Critical rules:

  • stock + flow is rejected β€” apply a flow to a stock with timeseries.accumulate
  • stock * rate is rejected β€” use debt.accrue(rate) so day-count and period length apply correctly

Units and currency

  • Currency codes (USD, GBP, …) are first-class series units set on construction: unit="USD" or unit=timeseries.usd. They live on the series value (value.unit), not on the expression form’s Format β†’ Currency (ccy) control.
  • Form ccy / Scale only affect number (and list-of-number) display. They do not change a timeseries unit and do not perform FX.
  • USD + GBP fails until you convert: usd_series.convert("GBP", fx) where fx is target per source (GBP per 1 USD).
  • Omitting unit= yields dimensionless (unit: "1"). Then convert fails (source series is not a pure currency unit).
  • Prefer convert over revenue * fx: multiply keeps the left-hand unit (still USD) even when values scale.
  • Rates and ratios are decimals in storage: 0.05 = 5%, 0.65 = 65% margin β€” not 5 or 65.
  • Dates are UTC only (YYYY-MM-DD). No wall-clock time zones.
  • Missing values use an explicit validity mask (not silent zeros). Arithmetic propagates NA: 10 + NA = NA until you fill_na / ffill / interpolate.

First-class series and timeline values

A series or timeline is stored as a tagged map, so the UI and evaluator treat it as a domain type (not a free-form dictionary).

ConceptHow it works
Return a seriesExpression ends with a series object (e.g. data_center + gaming). Do not peel off .values if dependents should keep series semantics.
Stored shapeDiscriminator type: "timeseries.series" or type: "timeseries.timeline" (also event series, regression, calendar).
Round-tripAliases and related() rehydrate the tag into a live object: + - * /, .accrue, .lag, … work without wrapping again.
Leaf listsPlain numeric lists need one explicit timeseries.series(values=…, start=…, …) at the boundary. After that, pass the series by alias.
Graph cardSeries, timeline, event-series, and regression results get bespoke SVG cards.
Form ResultOrdered text: start β†’ end Β· frequency Β· unit Β· N periods Β· M values.
Display titleFallback chain: series name β†’ node alias β†’ comment snippet β†’ kind (e.g. FLOW).
Domain errorsKind/unit/timeline mismatches mark the node with a warning and keep the last good value when possible; the cascade is not cancelled.

Example chain once intermediates return series:

# leaf (once)
timeseries.series(values=dc_units_list, start="2025-01-01", frequency="quarterly", kind="flow", unit="USD", name="data_center")

# dependents β€” aliases are already series
data_center + gaming + auto + other
debt.accrue(int_rate, daycount="actual365")

Module constants

String helpers you can pass into constructors (plain strings such as "stock" or "quarterly" also work):

ConstantValueUse
timeseries.stock"stock"kind
timeseries.flow"flow"kind
timeseries.rate"rate"kind
timeseries.index"index"kind
timeseries.ratio"ratio"kind
timeseries.change"change"kind
timeseries.daily"daily"frequency
timeseries.business_day"business_day"frequency (needs calendar)
timeseries.monthly"monthly"frequency
timeseries.quarterly"quarterly"frequency
timeseries.yearly"yearly"frequency
timeseries.gbp"GBP"unit
timeseries.usd"USD"unit
timeseries.actual365"actual365"day-count for accrue
timeseries.calendar_day"calendar_day"every day is a business day
timeseries.calendar_weekend"weekend"Mon–Fri business calendar

Custom holiday sets use timeseries.calendar(...) (not a string constant).


Constructors

timeseries.calendar(holidays?, weekends?)

Builds a business calendar from explicit non-business UTC dates (company shutdowns, exchange holidays).

ArgumentRequiredDefaultNotes
holidaysno[]List of "YYYY-MM-DD" UTC dates treated as non-business
weekendsnoTrueIf true, Saturday and Sunday are also non-business
# Mon–Fri only (same as calendar="weekend")
timeseries.calendar()

# Weekends + custom holidays
timeseries.calendar(holidays=["2025-12-25", "2026-01-01"])

# Holiday dates only (Saturdays still count as business unless listed)
timeseries.calendar(holidays=["2025-12-25"], weekends=False)

# Extend a base calendar
timeseries.calendar().with_holidays(["2025-12-25", "2026-01-01"])

Attributes / methods: name, holidays, weekends, is_business_day("YYYY-MM-DD"), with_holidays([...]).

Pass the calendar into timeline(..., calendar=cal). Named string calendars still work: "weekend", "calendar_day".

timeseries.timeline(start, periods, frequency?, calendar?)

Immutable timeline of calendar periods (half-open [start, end) per period).

ArgumentRequiredDefaultNotes
startyesβ€”"YYYY-MM-DD" UTC
periodsyesβ€”Positive integer
frequencyno"monthly"daily / business_day / weekly / monthly / quarterly / yearly
calendarnocalendar dayString name ("weekend") or a calendar object. Required for business_day.

Attributes: start, end, periods, frequency.

timeseries.timeline("2025-01-01", 8, frequency="quarterly")
timeseries.timeline("2025-01-01", 8, frequency=timeseries.quarterly)
timeseries.timeline("2025-01-01", 10, frequency="business_day", calendar="weekend")

Returning a timeline stores a tagged object and shows a timeline card. Dependents can use it for series.resample(shared_timeline) or events.project(shared_timeline) without rebuilding dates by hand.

timeseries.series(...)

Creates a regular series on a timeline. Values are dense by default (all valid).

Form A β€” from a timeline

ArgumentRequiredDefaultNotes
timelineyes*β€”From timeseries.timeline
valuesyesβ€”List of numbers; length = timeline periods
kindno"stock"See kinds above
unitnodimensionlesse.g. "USD"
nameno""Label for errors/display
# In a *script* you can assign; in an *expression* nest instead:
timeseries.series(
    timeseries.timeline("2025-01-01", 4, frequency="quarterly"),
    [12.0, 13.0, 14.0, 15.0],
    kind="flow",
    unit="USD",
    name="data_center",
)

Form B β€” one-shot (preferred in expression nodes)

ArgumentRequiredDefaultNotes
valuesyesβ€”List of numbers
startyes*β€”"YYYY-MM-DD" if no timeline
periodsnolen(values)Override period count
frequencyno"monthly"
kindno"stock"
unitnodimensionless
nameno""
timeseries.series(
    values=[12.0, 13.0, 14.0, 15.0],
    start="2025-01-01",
    frequency="quarterly",
    kind="flow",
    unit=timeseries.usd,
    name="data_center",
)

Attributes: values, valid, has_na, kind, unit, name, len.

Stored series shape (conceptual):

{
  type: "timeseries.series",
  name, kind, unit,
  values,                      # numbers; null where NA
  valid?,                      # only when any observation is missing
  periods, start, end, frequency
}

timeseries.accumulate(stock=, flow=)

Applies a flow onto a stock after alignment:

result[t] = stock[t] + cumsum(flow)[t]

timeseries.accumulate(
    stock=timeseries.series(values=[100.0, 100.0, 100.0], start="2025-01-01", kind="stock", unit="USD"),
    flow=timeseries.series(values=[10.0, 5.0, 1.0], start="2025-01-01", kind="flow", unit="USD"),
)
# values β†’ [110, 115, 116]

Use for opening debt + net new borrowing, cash build-up, and similar. Do not write debt + borrowing.

timeseries.ones(timeline, kind?, unit?)

Series of 1.0 on a timeline (default kind ratio, dimensionless). Useful for keep-rates:

timeseries.ones(timeseries.timeline("2025-01-01", 8, frequency="quarterly")) - tax_rate
# or simply, for dimensionless ratio series:
1 - tax_rate

Also: series.ones_like() returns ones on the same timeline.

timeseries.events(...) β€” irregular observations

An event series is a bag of dated values that do not live on a regular grid:

Real-world exampleWhy it is an event
Debt issuance of $2bn on 2025-03-14Once, on an arbitrary day
Special dividend / one-off tax refundPoint adjustment
Rate announcement effective 2025-06-01Level that holds from then on (as-of)
Capex invoices on irregular datesSum into the quarter that contains them
timeseries.events(
    events=[
        ["2025-03-14", 2.0],
        ["2025-09-30", 1.5],
        ["2026-01-10", -0.5],
    ],
    kind="flow",
    unit="USD",
    name="debt_issuance",
)
ArgumentRequiredDefaultNotes
eventsyesβ€”List of ["YYYY-MM-DD", number] pairs
kindno"flow"Semantic kind after projection
unitnodimensionless
nameno""

Methods:

MethodResult
events.project(timeline, method?)Regular Series on that timeline
events.asof(timeline)Same as project(..., method="asof")

Project methods

Periods are half-open [start, end). An event at 2025-04-01 falls in the period that contains that instant.

methodBehaviourTypical use
"sum" (default)Sum all events whose date falls in the periodIssuances, invoices
"last"Last event in the periodEnd-of-period reading
"first"First event in the periodOpening spike
"impulse"Point mass in containing periodSingle hit placement
"asof"Latest event with date < period_end, carried forwardRate/announcement levels

Periods with no contributing events are NA (not zero), except asof which forward-fills once a first event has been seen.

EventSeries  ──project(timeline, method)──►  Series  ──+ / accrue / accumulate──►  model
   (sparse)              β”‚                    (regular)
                         └── asof ───────────► step series of β€œlatest known”

Debt issuance β†’ quarterly borrowing flow:

timeseries.events(
    events=[["2025-03-14", 2.0], ["2025-09-30", 1.5]],
    kind="flow",
    unit="USD",
    name="issuances",
).project(
    timeseries.timeline("2025-01-01", 8, frequency="quarterly"),
    method="sum",
)

If model_tl and issuances are aliased nodes:

issuances.project(model_tl, method="sum")

Policy rate announcements β†’ as-of rate series:

timeseries.events(
    events=[
        ["2024-12-15", 0.045],
        ["2025-06-12", 0.050],
        ["2025-11-01", 0.0475],
    ],
    kind="rate",
    name="fed_path",
).asof(timeseries.timeline("2025-01-01", 8, frequency="quarterly"))

One-off P&L item (empty quarters must become zero before subtraction):

op_income - timeseries.events(
    events=[["2025-08-20", 0.4]],
    kind="flow",
    unit="USD",
    name="litigation",
).project(model_tl, method="sum").fill_na(0.0)

Shared timeline node (recommended graph shape):

model_tl (timeline expression)
    β”‚
    β”œβ”€β–Ί issuances.project(model_tl, method="sum")     β†’ issuance_flow
    β”œβ”€β–Ί coupons.project(model_tl, method="sum")       β†’ coupon_flow
    └─► rate_events.asof(model_tl)                    β†’ int_rate

timeseries.frame(timeline)

Named collection of series that share one timeline (matrix helper). Methods: .set(name, series), .get(name), .matrix(names=[...]). Prefer separate expression nodes in graphs; frame is mainly for scripts or dense multi-column hand-offs.

timeseries.regression / timeseries.calibrate

Fit a linear model after aligning predictors and dropping incomplete rows. Prefer one expression node per series, then a model node, then a forecast node.

timeseries.regression(
    gdp,
    [
        interest_rate.lag(2),
        inflation.lag(1),
        unemployment,
    ],
)
gdp_model.predict([
    projected_rate.lag(2),
    projected_inflation.lag(1),
    projected_unemployment,
])
timeseries.calibrate(
    target=gdp,
    basis=[
        interest_rate.lag(2),
        inflation.lag(1),
        unemployment,
    ],
    loss="rmse",
)

Inspect: gdp_model.intercept, gdp_model.coefficients, gdp_model.r_squared, gdp_model.rmse, gdp_model.observations.

Tip: drivers that move in lockstep produce a singular design matrix. Use independent variation, and enough complete rows after lags.


Series methods

Unless noted, methods return a new series (immutable style).

Arithmetic

OpRule (simplified)Result kind (typical)
a + b / a - bSame kind; compatible units; NA propagatesSame as operands
a * bUnits multiply; kinds must be sensiblee.g. flow Γ— ratio β†’ flow
a / bUnits dividestock/stock β†’ ratio; flow/stock β†’ rate
a * k / a / kScalar kSame series
1 - a / k - aDimensionless series (e.g. tax rate)Same kind

Rejected by design: stock + flow, stock * rate, USD + GBP (convert first).

When two series have different timelines, the engine aligns them (default: finest frequency, intersection of ranges) and resamples by kind. Override with a.align(b, frequency=..., range=...).

# Gross profit = revenue Γ— margin
timeseries.series(values=[10.0, 12.0], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD") \
  * timeseries.series(values=[0.70, 0.72], start="2025-01-01", frequency="quarterly", kind="ratio")

1 - tax_rate
pretax * (1 - tax_rate)

Scale and FX

MethodPurpose
scale(k)Scenario multiplier (same unit/kind) β€” e.g. rev.scale(1.1) = +10%
convert(target, fx)FX: multiply by fx and retag unit to target
with_name(label)Copy with a new display name (charts / cards)

fx convention: units of target currency per 1 unit of source. Example: usd_gbp = 0.79 means Β£0.79 per $1.

rev.scale(1.1)
revenue.convert("GBP", usd_gbp).with_name("revenue_gbp")

Common mistakes:

What you didWhat happens
Built revenue without unit="USD"Unit is "1"; convert errors
Set Format tab Currency onlyDisplay formatting only β€” series card still reads value.unit
Wrote revenue * usd_gbpValues scale; unit stays USD
Charted bare convert next to sourceBoth keep the same name β€” chain .with_name(...)

Lag, lead, diff, growth

bank_rate.lag(1)
closing_debt.lag(1).fill_na(opening_seed_value)
debt.diff()
revenue.pct_change()     # QoQ growth as decimal, e.g. 0.08 = +8%

Leading/trailing gaps from lag/lead are NA (not zero).

Cumulative

fcf.cumsum()       # flow β†’ stock
growth.compound()  # relative change / rate β†’ index

Missing values

MethodEffect
fill_na(value)Replace NA with constant
ffill()Forward fill (leading NA remain)
bfill()Backward fill (trailing NA remain)
interpolate()Linear between valid neighbors
coalesce(other)Prefer self; take other where NA
drop_na()Shorten to contiguous valid block
is_na()Ratio series of 0/1 flags
issuance_flow.fill_na(0.0)
rate_path.ffill()
sparse.coalesce(fallback)

Windows and EWM

Trailing windows: integer observation count or duration string ("12m", "30d", "4q").

revenue.rolling_mean(4)
revenue.rolling_mean(window="4q")
revenue.rolling_sum("12m")
revenue.rolling_std(4)
inflation.ewm(alpha=0.2)
inflation.ewm(span=6)

Align and resample

annual_cpi.asof(model_tl)
a.align(b, frequency="finest", range="intersection")

# Kind defaults apply when method is omitted (stock β†’ last/ffill, flow β†’ sum/distribute, …)
q_rev.resample(timeseries.timeline("2025-01-01", 12, frequency="monthly"))
q_rev.resample(timeseries.timeline("2025-01-01", 12, frequency="monthly"), method="linear")

Stats

Valid observations only; NA is skipped. Pairwise stats align timelines first.

revenue.mean()
revenue.std()
revenue.variance()
revenue.min()
revenue.max()
revenue.quantile(0.9)
revenue.corr(other)
revenue.covariance(other)

Accrue (financing primitive)

Balance must be a stock; rate a rate. Result is a flow in the balance’s unit:

interest[t] β‰ˆ balance[t] Γ— period_factor(rate[t], period[t], day-count)

debt.accrue(interest_rate)
debt.accrue(interest_rate, daycount="actual365")
debt.accrue(interest_rate, daycount=timeseries.actual365)

For quarterly models, a 5% annual rate does not become 5% per quarter. Accrual uses each quarter’s actual year fraction (e.g. ~90/365).


Modelling pattern on the graph

LayerWhat lives there
Assumption nodesDrivers: demand, price, margin, rates, tax rate (edit these)
Driver / intermediate nodesUnit demand Γ— price β†’ segment revenue
P&L nodesTotals, gross profit, opex, EBIT, tax, NI
Balance / financing nodesDebt stock, borrowing/repayment flows, accrued interest
Optional cash nodesFCF, cash stock, repayment policy

Edges mean dependency: the expression node listens to the nodes it reads. Topology is the engine.

Recommended habits

  1. Alias every input; name expression nodes after the output metric.
  2. Prefer many small nodes over one giant formula β€” the graph is the audit trail.
  3. Share one timeline node and resample / events.project onto it.
  4. Return the series object from intermediates (not only .values).
  5. Use accrue / accumulate for time and stock–flow economics.

How to write formulas in the expression editor: Expressions. Multi-line packages: Python scripting.


Worked example β€” NVIDIA driver network

Purpose

Model NVIDIA as a forward-looking driver network, not a full three-statement accounting replica:

  • Segment revenue driven by demand Γ— price (causality, not a single β€œrevenue” plug)
  • P&L waterfall to net income
  • Debt stock β†’ accrued interest β†’ earnings
  • Optional feedback: cash / repayment β†’ debt β†’ future interest

Horizon: 8 quarters from 2025-01-01 (illustrative numbers, not a forecast). Scale: USD billions as plain numbers with unit="USD".

Network sketch

                    NVIDIA financial drivers
                              β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                     β”‚                     β”‚
     Revenue               Expenses              Financing
        β”‚                     β”‚                     β”‚
   β”Œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”
   β”‚    β”‚    β”‚           β”‚    β”‚    β”‚          β”‚           β”‚
  DC  Gaming Auto      COGS  R&D  SG&A      Debt     Int. rate
   β”‚    β”‚    β”‚           β”‚    β”‚    β”‚          β”‚           β”‚
   β””β”€β”€β”¬β”€β”΄β”€β”¬β”€β”€β”˜           β”‚    β”‚    β”‚          β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
      β”‚   β”‚              β”‚    β”‚    β”‚                β”‚
      β–Ό   β”‚              β”‚    β”‚    β”‚                β–Ό
 Total Revenue           β”‚    β”‚    β”‚         Interest expense
      β”‚                  β”‚    β”‚    β”‚                β”‚
      β”œβ”€β”€ Γ— gross_margin β”˜    β”‚    β”‚                β”‚
      β–Ό                       β”‚    β”‚                β”‚
 Gross profit                 β”‚    β”‚                β”‚
      β”œβ”€β”€ βˆ’ R&D β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚                β”‚
      β”œβ”€β”€ βˆ’ SG&A β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                β”‚
      β–Ό                                             β”‚
 Operating income                                   β”‚
      β”œβ”€β”€ βˆ’ interest β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
      β–Ό
 Pre-tax income
      β”œβ”€β”€ Γ— (1 βˆ’ tax_rate)
      β–Ό
 Net income

Causal revenue (optional richer demo):

AI compute demand ──► DC unit demand ──┬──► Data Center revenue
                         GPU price β”€β”€β”€β”€β”˜
Gaming demand ──┬──► Gaming revenue
Gaming price β”€β”€β”€β”˜

Node catalogue

Use one Nodlin node per series. Suggested aliases match expression variables.

AliasKindUnitRole
dc_unitsflowdimensionlessData center GPU units (m)
dc_priceflowUSDASP β€” keep scale consistent with units
data_centerflowUSDDC revenue
gaming_unitsflowβ€”Gaming units
gaming_priceflowUSDGaming ASP
gamingflowUSDGaming revenue
autoflowUSDAutomotive revenue
otherflowUSDOther revenue
total_revenueflowUSDSum of segments
gross_marginratioβ€”e.g. 0.75
gross_profitflowUSDrevenue Γ— margin
cogsflowUSDrevenue Γ— (1 βˆ’ margin)
rdflowUSDR&D expense
sgaflowUSDSG&A
op_incomeflowUSDEBIT-like
debtstockUSDDebt outstanding
borrowflowUSDNew borrowing
repayflowUSDRepayments
closing_debtstockUSDAfter borrow/repay
int_raterateβ€”Annual financing rate (decimal)
interestflowUSDAccrued interest
pretaxflowUSDEBT
tax_rateratioβ€”Effective tax rate
net_incomeflowUSDNI

Input series (illustrative)

Eight quarters, quarterly frequency, start 2025-01-01.

timeseries.series(
    values=[2.0, 2.2, 2.5, 2.8, 3.0, 3.2, 3.4, 3.6],
    start="2025-01-01",
    frequency="quarterly",
    kind="flow",
    name="dc_units",
)
AliasExample values (8Q)
dc_units[2.0, 2.2, 2.5, 2.8, 3.0, 3.2, 3.4, 3.6]
dc_price[4.0, 4.0, 4.1, 4.1, 4.2, 4.2, 4.3, 4.3]
gaming_units[1.5, 1.5, 1.4, 1.4, 1.3, 1.3, 1.2, 1.2]
gaming_price[0.8, 0.8, 0.8, 0.75, 0.75, 0.75, 0.7, 0.7]
auto[0.4, 0.4, 0.45, 0.45, 0.5, 0.5, 0.55, 0.55]
other[0.3]*8
gross_margin[0.75]*8
rd[2.5, 2.6, 2.7, 2.8, 2.9, 3.0, 3.1, 3.2]
sga[1.0]*8
debt[10.0]*8 opening path, or evolve via accumulate
int_rate[0.045]*8 (4.5% annual)
tax_rate[0.15]*8

Expression nodes (copy-ready)

Assumes aliases already resolve to series. Only leaf plain lists need an explicit wrap:

timeseries.series(
    values=dc_units,          # plain list from a non-series node
    start="2025-01-01",
    frequency="quarterly",
    kind="flow",
    unit="USD",
    name="dc_units",
)

After that, dependents use the alias directly.

Data Center revenue (units Γ— price):

dc_units * dc_price

Gaming revenue:

gaming_units * gaming_price

Total revenue:

data_center + gaming + auto + other

Gross profit and COGS:

total_revenue * gross_margin
total_revenue * (1 - gross_margin)

1 - gross_margin is supported for dimensionless ratio series. Equivalent COGS:

total_revenue - gross_profit

Operating income:

gross_profit - rd - sga

Interest expense β€” do not multiply:

# Wrong (rejected or economically sloppy):
# debt * int_rate

# Correct:
debt.accrue(int_rate, daycount="actual365")

Pre-tax and net income:

op_income - interest
pretax * (1 - tax_rate)

Or as its own node keep_ratio:

1 - tax_rate
pretax * keep_ratio

Debt evolution

Static debt is a single stock assumption. Dynamic debt uses flows:

opening_debt (stock)
borrow (flow)     repay (flow)
        \         /
         accumulate β†’ closing_debt (stock)
              β”‚
              β–Ό
         closing_debt.accrue(int_rate) β†’ interest (flow)
timeseries.accumulate(stock=opening_debt, flow=(borrow - repay))

If borrow - repay is not yet a single flow node, create net_borrow = borrow - repay first, then:

timeseries.accumulate(stock=opening_debt, flow=net_borrow)

lag(1) leaves t0 as NA β€” seed it:

closing_debt.lag(1).fill_na(10.0)   # or coalesce with an opening_debt series
closing_debt.accrue(int_rate, daycount="actual365")

Optional: irregular financing as events

# node: model_tl
timeseries.timeline("2025-01-01", 8, frequency="quarterly")
# node: issuances
timeseries.events(
    events=[["2025-03-14", 2.0], ["2025-09-01", 1.0]],
    kind="flow",
    unit="USD",
    name="issuances",
)
# node: issuance_flow
issuances.project(model_tl, method="sum").fill_na(0.0)
# node: closing_debt
timeseries.accumulate(stock=opening_debt, flow=issuance_flow - repay)

Policy rate path from announcement dates:

timeseries.events(
    events=[["2024-12-18", 0.045], ["2025-06-19", 0.05]],
    kind="rate",
    name="policy_rate_events",
).asof(model_tl)
closing_debt.accrue(policy_rate, daycount="actual365")

Optional cash feedback

net_income + da - capex - Ξ”NWC β†’ fcf
fcf β†’ repay policy / cash stock
repay β†’ lower debt β†’ lower interest β†’ higher NI
net_income + da - capex - nwc_change
timeseries.accumulate(stock=cash, flow=fcf)

Standalone smoke tests (no graph)

Accrue one quarter of interest on $10bn at 4.5% annual:

timeseries.series(values=[10.0, 10.0], start="2025-01-01", frequency="quarterly", kind="stock", unit="USD", name="debt").accrue(
    timeseries.series(values=[0.045, 0.045], start="2025-01-01", frequency="quarterly", kind="rate", name="int_rate"),
    daycount="actual365",
).values[0]

Total revenue from four segments:

(
  timeseries.series(values=[8.0, 9.0], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD")
+ timeseries.series(values=[1.2, 1.1], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD")
+ timeseries.series(values=[0.4, 0.5], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD")
+ timeseries.series(values=[0.3, 0.3], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD")
).values

Gross profit:

(
  timeseries.series(values=[10.0, 12.0], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD")
* timeseries.series(values=[0.75, 0.76], start="2025-01-01", frequency="quarterly", kind="ratio")
).values

Debt after borrowing:

timeseries.accumulate(
    stock=timeseries.series(values=[10.0, 10.0, 10.0], start="2025-01-01", frequency="quarterly", kind="stock", unit="USD"),
    flow=timeseries.series(values=[1.0, 0.5, -0.5], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD"),
).values

Keep after tax:

(
  timeseries.series(values=[10.0, 12.0], start="2025-01-01", frequency="quarterly", kind="flow", unit="USD")
* (1 - timeseries.series(values=[0.15, 0.15], start="2025-01-01", frequency="quarterly", kind="ratio"))
).values

Events β†’ quarterly flow:

timeseries.events(
    events=[["2025-02-10", 1.0], ["2025-02-20", 0.5]],
    kind="flow",
    unit="USD",
).project(
    timeseries.timeline("2025-01-01", 4, frequency="monthly"),
    method="sum",
).fill_na(0.0).values

Illegal ops (should error):

timeseries.series(values=[10.0], start="2025-01-01", kind="stock", unit="USD")
+ timeseries.series(values=[1.0], start="2025-01-01", kind="flow", unit="USD")
timeseries.series(values=[10.0], start="2025-01-01", kind="stock", unit="USD")
* timeseries.series(values=[0.05], start="2025-01-01", kind="rate")

Suggested build order

  1. Timeline convention: quarterly, 2025-01-01, N quarters.
  2. Leaf assumptions: prices, units, margins, opex, tax, rate.
  3. Segment revenues (units * price).
  4. total_revenue, gross_profit, cogs, op_income.
  5. debt + int_rate β†’ interest via accrue.
  6. pretax, net_income.
  7. Add borrow / repay / accumulate for closing debt.
  8. Optional: FCF and repayment feedback.

That sequence shows why Nodlin is a reactive knowledge graph: financing cost is not a spreadsheet cell multiply β€” it is a typed temporal operation on related stocks and rates, recalculated when either side changes.


Operator cheat-sheet

IntentExpression
Sum segment revenuesa + b + c
Apply marginrevenue * margin
COGS from GPrevenue - gross_profit
Keep after tax / margin1 - tax_rate, pretax * (1 - tax_rate)
Subtract opexgp - rd - sga
Interestdebt.accrue(rate, daycount="actual365")
Stock ← stock + flowtimeseries.accumulate(stock=s, flow=f)
FXrevenue.convert("GBP", usd_gbp).with_name("revenue_gbp") with unit="USD" on revenue
Rename seriesseries.with_name("label")
Growthlevel.pct_change()
Smoothflow.rolling_mean(4) or flow.rolling_mean("12m")
EWMseries.ewm(alpha=0.2)
Delayseries.lag(1) or series.lag("6m") (leading NA β€” often .fill_na(...))
Statsmean(), std(), quantile(p), corr(other)
Regressiontimeseries.regression(y, [x1, x2]) β†’ .predict([...])
Calibratetimeseries.calibrate(target=…, basis=[…], loss="rmse")
Change frequencyseries.resample(timeline, method?)
Scenarioseries.scale(1.1)
Sparse dated hitsevents.project(tl, method="sum").fill_na(0)
Announcement pathevents.asof(tl) then debt.accrue(rate)
Fill holesseries.ffill(), fill_na(0), interpolate()
Business calendartimeseries.calendar(holidays=["2025-12-25"]) then timeline(..., frequency="business_day", calendar=cal)

Practical tips

  1. Pick one scale ($m or $bn) and stick to it across the graph.
  2. Rates and ratios are decimals (0.045, not 4.5).
  3. Alias every input the expression reads; name nodes after the output metric. Alias doubles as the series card title when name is empty.
  4. Prefer many small nodes over one giant formula.
  5. Use accrue / accumulate whenever the economics involve time or stock–flow.
  6. Expression nodes = one expression. Multi-step packages use the same timeseries module β€” see Python scripting.
  7. When a result should feed another node as a series, return the series object (not only .values).
  8. Share one timeline node and project/resample onto it when frequencies or event dates differ.
  9. Events vs series: continuous paths stay series; dated one-offs and announcements are events, then project.
  10. After project(method="sum"), empty periods are NA β€” usually .fill_na(0.0) before adding into a P&L. After asof, leading periods stay NA until the first event.
  11. Domain eval errors soft-fail on expression nodes: fix the formula on the warning node; siblings and prior good values keep the cascade moving.
  12. The expression form Chart action charts a list or timeseries.series. Project event series first, then chart the resulting series.

  • Expressions β€” single-line formulas, aliases, related(), timeseries in the expression editor
  • Python scripting β€” multi-line Starlark packages using timeseries
  • Golang API β€” external agents and the Go timeseries package
  • Forms β€” expression editor control
John Harrington
Authors
John Harrington
Founder
A technology leader and software architect with 20+ years in financial services and enterprise systems, founder of Nodlin Technologies LtdοΏΌ, building connected AI-driven operational intelligence platforms.