Timeseries in Nodlin

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:
- Expression nodes β single-line formulas (examples on this page use that style)
- Starlark scripts / packages β multi-line pipelines with the same
timeseriesname - Go agents β package
github.com/johnha/nodlinGo/v2/timeseries
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:
| Dimension | Meaning | Examples |
|---|---|---|
| Timeline | When regular periods exist | Quarterly from 2025-01-01 for 12 periods |
| Values | How much each period | [12.4, 13.1, β¦] $bn |
| Valid | Whether each period is observed (optional) | missing mid-history, not yet published |
| Unit | In what units | USD, dimensionless |
| Kind | What the numbers mean over time | Stock, 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)
| Kind | Meaning | Typical use |
|---|---|---|
stock | Level at a point in time | Debt outstanding, cash balance |
flow | Quantity over a period | Revenue, COGS, interest expense |
rate | Rate applying over time (usually annual) | Coupon / financing rate |
ratio | Dimensionless relationship (does not accrue) | Gross margin, tax rate, LTV |
index | Level with arbitrary base | Cumulative growth index |
change | Period-to-period change | Growth after pct_change |
Critical rules:
stock + flowis rejected β apply a flow to a stock withtimeseries.accumulatestock * rateis rejected β usedebt.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"orunit=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 + GBPfails until you convert:usd_series.convert("GBP", fx)wherefxis target per source (GBP per 1 USD).- Omitting
unit=yields dimensionless (unit: "1"). Thenconvertfails (source series is not a pure currency unit). - Prefer
convertoverrevenue * fx: multiply keeps the left-hand unit (stillUSD) even when values scale. - Rates and ratios are decimals in storage:
0.05= 5%,0.65= 65% margin β not5or65. - 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 = NAuntil youfill_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).
| Concept | How it works |
|---|---|
| Return a series | Expression ends with a series object (e.g. data_center + gaming). Do not peel off .values if dependents should keep series semantics. |
| Stored shape | Discriminator type: "timeseries.series" or type: "timeseries.timeline" (also event series, regression, calendar). |
| Round-trip | Aliases and related() rehydrate the tag into a live object: + - * /, .accrue, .lag, β¦ work without wrapping again. |
| Leaf lists | Plain numeric lists need one explicit timeseries.series(values=β¦, start=β¦, β¦) at the boundary. After that, pass the series by alias. |
| Graph card | Series, timeline, event-series, and regression results get bespoke SVG cards. |
| Form Result | Ordered text: start β end Β· frequency Β· unit Β· N periods Β· M values. |
| Display title | Fallback chain: series name β node alias β comment snippet β kind (e.g. FLOW). |
| Domain errors | Kind/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):
| Constant | Value | Use |
|---|---|---|
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).
| Argument | Required | Default | Notes |
|---|---|---|---|
holidays | no | [] | List of "YYYY-MM-DD" UTC dates treated as non-business |
weekends | no | True | If 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).
| Argument | Required | Default | Notes |
|---|---|---|---|
start | yes | β | "YYYY-MM-DD" UTC |
periods | yes | β | Positive integer |
frequency | no | "monthly" | daily / business_day / weekly / monthly / quarterly / yearly |
calendar | no | calendar day | String 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
| Argument | Required | Default | Notes |
|---|---|---|---|
timeline | yes* | β | From timeseries.timeline |
values | yes | β | List of numbers; length = timeline periods |
kind | no | "stock" | See kinds above |
unit | no | dimensionless | e.g. "USD" |
name | no | "" | 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)
| Argument | Required | Default | Notes |
|---|---|---|---|
values | yes | β | List of numbers |
start | yes* | β | "YYYY-MM-DD" if no timeline |
periods | no | len(values) | Override period count |
frequency | no | "monthly" | |
kind | no | "stock" | |
unit | no | dimensionless | |
name | no | "" |
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 example | Why it is an event |
|---|---|
| Debt issuance of $2bn on 2025-03-14 | Once, on an arbitrary day |
| Special dividend / one-off tax refund | Point adjustment |
| Rate announcement effective 2025-06-01 | Level that holds from then on (as-of) |
| Capex invoices on irregular dates | Sum 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",
)
| Argument | Required | Default | Notes |
|---|---|---|---|
events | yes | β | List of ["YYYY-MM-DD", number] pairs |
kind | no | "flow" | Semantic kind after projection |
unit | no | dimensionless | |
name | no | "" |
Methods:
| Method | Result |
|---|---|
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.
method | Behaviour | Typical use |
|---|---|---|
"sum" (default) | Sum all events whose date falls in the period | Issuances, invoices |
"last" | Last event in the period | End-of-period reading |
"first" | First event in the period | Opening spike |
"impulse" | Point mass in containing period | Single hit placement |
"asof" | Latest event with date < period_end, carried forward | Rate/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
| Op | Rule (simplified) | Result kind (typical) |
|---|---|---|
a + b / a - b | Same kind; compatible units; NA propagates | Same as operands |
a * b | Units multiply; kinds must be sensible | e.g. flow Γ ratio β flow |
a / b | Units divide | stock/stock β ratio; flow/stock β rate |
a * k / a / k | Scalar k | Same series |
1 - a / k - a | Dimensionless 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
| Method | Purpose |
|---|---|
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 did | What happens |
|---|---|
Built revenue without unit="USD" | Unit is "1"; convert errors |
| Set Format tab Currency only | Display formatting only β series card still reads value.unit |
Wrote revenue * usd_gbp | Values scale; unit stays USD |
| Charted bare convert next to source | Both 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
| Method | Effect |
|---|---|
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
| Layer | What lives there |
|---|---|
| Assumption nodes | Drivers: demand, price, margin, rates, tax rate (edit these) |
| Driver / intermediate nodes | Unit demand Γ price β segment revenue |
| P&L nodes | Totals, gross profit, opex, EBIT, tax, NI |
| Balance / financing nodes | Debt stock, borrowing/repayment flows, accrued interest |
| Optional cash nodes | FCF, cash stock, repayment policy |
Edges mean dependency: the expression node listens to the nodes it reads. Topology is the engine.
Recommended habits
- Alias every input; name expression nodes after the output metric.
- Prefer many small nodes over one giant formula β the graph is the audit trail.
- Share one timeline node and
resample/events.projectonto it. - Return the series object from intermediates (not only
.values). - Use
accrue/accumulatefor 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.
| Alias | Kind | Unit | Role |
|---|---|---|---|
dc_units | flow | dimensionless | Data center GPU units (m) |
dc_price | flow | USD | ASP β keep scale consistent with units |
data_center | flow | USD | DC revenue |
gaming_units | flow | β | Gaming units |
gaming_price | flow | USD | Gaming ASP |
gaming | flow | USD | Gaming revenue |
auto | flow | USD | Automotive revenue |
other | flow | USD | Other revenue |
total_revenue | flow | USD | Sum of segments |
gross_margin | ratio | β | e.g. 0.75 |
gross_profit | flow | USD | revenue Γ margin |
cogs | flow | USD | revenue Γ (1 β margin) |
rd | flow | USD | R&D expense |
sga | flow | USD | SG&A |
op_income | flow | USD | EBIT-like |
debt | stock | USD | Debt outstanding |
borrow | flow | USD | New borrowing |
repay | flow | USD | Repayments |
closing_debt | stock | USD | After borrow/repay |
int_rate | rate | β | Annual financing rate (decimal) |
interest | flow | USD | Accrued interest |
pretax | flow | USD | EBT |
tax_rate | ratio | β | Effective tax rate |
net_income | flow | USD | NI |
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",
)
| Alias | Example 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
- Timeline convention: quarterly,
2025-01-01, N quarters. - Leaf assumptions: prices, units, margins, opex, tax, rate.
- Segment revenues (
units * price). total_revenue,gross_profit,cogs,op_income.debt+int_rateβinterestviaaccrue.pretax,net_income.- Add
borrow/repay/accumulatefor closing debt. - 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
| Intent | Expression |
|---|---|
| Sum segment revenues | a + b + c |
| Apply margin | revenue * margin |
| COGS from GP | revenue - gross_profit |
| Keep after tax / margin | 1 - tax_rate, pretax * (1 - tax_rate) |
| Subtract opex | gp - rd - sga |
| Interest | debt.accrue(rate, daycount="actual365") |
| Stock β stock + flow | timeseries.accumulate(stock=s, flow=f) |
| FX | revenue.convert("GBP", usd_gbp).with_name("revenue_gbp") with unit="USD" on revenue |
| Rename series | series.with_name("label") |
| Growth | level.pct_change() |
| Smooth | flow.rolling_mean(4) or flow.rolling_mean("12m") |
| EWM | series.ewm(alpha=0.2) |
| Delay | series.lag(1) or series.lag("6m") (leading NA β often .fill_na(...)) |
| Stats | mean(), std(), quantile(p), corr(other) |
| Regression | timeseries.regression(y, [x1, x2]) β .predict([...]) |
| Calibrate | timeseries.calibrate(target=β¦, basis=[β¦], loss="rmse") |
| Change frequency | series.resample(timeline, method?) |
| Scenario | series.scale(1.1) |
| Sparse dated hits | events.project(tl, method="sum").fill_na(0) |
| Announcement path | events.asof(tl) then debt.accrue(rate) |
| Fill holes | series.ffill(), fill_na(0), interpolate() |
| Business calendar | timeseries.calendar(holidays=["2025-12-25"]) then timeline(..., frequency="business_day", calendar=cal) |
Practical tips
- Pick one scale ($m or $bn) and stick to it across the graph.
- Rates and ratios are decimals (
0.045, not4.5). - Alias every input the expression reads; name nodes after the output metric. Alias doubles as the series card title when
nameis empty. - Prefer many small nodes over one giant formula.
- Use
accrue/accumulatewhenever the economics involve time or stockβflow. - Expression nodes = one expression. Multi-step packages use the same
timeseriesmodule β see Python scripting. - When a result should feed another node as a series, return the series object (not only
.values). - Share one timeline node and project/resample onto it when frequencies or event dates differ.
- Events vs series: continuous paths stay series; dated one-offs and announcements are events, then project.
- After
project(method="sum"), empty periods are NA β usually.fill_na(0.0)before adding into a P&L. Afterasof, leading periods stay NA until the first event. - Domain eval errors soft-fail on expression nodes: fix the formula on the warning node; siblings and prior good values keep the cascade moving.
- The expression form Chart action charts a list or
timeseries.series. Project event series first, then chart the resulting series.
Related documentation
- 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