Tutorial 1 — TimeSeriesEcon¶
A narrative port of the upstream Julia tutorial
TutorialsEcon.jl/1.TimeSeriesEcon,
showing only Python idioms in code blocks and dropping a Julia ↔ Python
note per section so anyone migrating from the Julia codebase can find the
original passage. Every code block on this page runs at mkdocs build
time via the markdown-exec
plugin — the same trick Documenter.jl plays with @repl tse blocks
upstream — so a broken example fails CI rather than silently rotting on
the site.
Section status¶
| # | Section | Status |
|---|---|---|
| 1 | Frequency and Time | 🟢 |
| 2 | Ranges (MITRange) |
🟢 |
| 3 | TSeries — Creation | 🟢 |
| 4 | TSeries — Access (read / write) | 🟢 |
| 5 | Arithmetic with TSeries | 🟢 |
| 6 | Shifts (lag / lead) | 🟢 |
| 7 | Diff and undiff | 🟢 |
| 8 | Moving average | 🟢 |
| 9 | Recursive assignments | 🟢 |
| 10 | Multi-variate Time Series | 🟢 |
| 11 | Plotting | 🟢 |
| 12 | Workspaces | 🟢 |
| 13 | MVTSeries vs Workspace | 🟢 |
| 14 | overlay |
🟢 |
| 15 | compare / @compare |
🟢 |
| 15a | reindex (Python add: not a tutorial-1 section upstream) |
🟢 |
| 16 | BDaily holidays | 🟢 |
| 17 | Options | 🟢 |
Legend: 🟢 ported · 🟡 partial · 🔵 stubbed (depends on a later milestone).
import base64
import io
import math
import matplotlib
matplotlib.use("Agg") # noqa: E402 — must precede pyplot import
import matplotlib.pyplot as plt
import numpy as np
import tsecon
from tsecon import (
BDaily,
Daily,
Duration,
HalfYearly,
MIT,
MITRange,
MVTSeries,
Monthly,
Quarterly,
TSeries,
Unit,
Weekly,
Workspace,
Yearly,
bdaily,
compare,
daily,
diff,
frequency_of,
lag,
lead,
mean,
mit2yp,
mm,
moving,
overlay,
pct,
period,
plot,
ppy,
qq,
rangeof,
rec,
rec_linear,
reindex,
shift,
undiff,
weekly,
year,
yy,
)
# Reproducible RNG for any `rand`-style examples below.
rng_np = np.random.default_rng(20260517)
def _show(fig, alt=""):
"""Encode a matplotlib Figure as a base64 PNG and print the <img> tag.
The surrounding fenced block needs `html="true"` so markdown-exec
forwards the captured stdout as raw HTML instead of wrapping it
in a code-formatted output block.
"""
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
plt.close(fig)
data = base64.b64encode(buf.getvalue()).decode("ascii")
print(f'<img src="data:image/png;base64,{data}" alt="{alt}" />')
1. Frequency and Time¶
In a time series the values are evenly spaced in time and each value is
labelled with the moment in which it occurred. TimeSeriesEconPy provides
the same two data types upstream's TimeSeriesEcon.jl does for these
concepts: a Frequency describes the spacing, an MIT
(moment-in-time) labels one point, and a Duration measures the
distance between two MITs of the same frequency.
Frequencies¶
The abstract type Frequency represents the idea of a sampling
cadence. All concrete frequencies are special cases. Four are
period-of-year (calendar) frequencies — Yearly, HalfYearly,
Quarterly, Monthly — and are defined by a number of periods per
year. Three are calendar-date frequencies that depend on the
Gregorian calendar: Weekly, BDaily (business-daily, Mon–Fri), and
Daily. The last, Unit, is not based on the calendar and simply
counts observations.
Typically you do not work with the Frequency types directly — you
get them via the constructor functions like qq() introduced below.
Every Frequency is a cached singleton: Yearly() is Yearly() is
True, so identity checks are valid.
End-months and end-days¶
Yearly, HalfYearly, and Quarterly have implicit default
end-months — 12 (December), 6 (June), and 3 (March) respectively.
Most uses of Yearly() implicitly mean Yearly(end_month=12). You
can pick a variant explicitly:
print(Yearly()) # default end_month = 12
print(Yearly(end_month=3)) # fiscal year ending in March
print(Quarterly()) # default end_month = 3
print(Quarterly(end_month=2)) # broadcaster's calendar
Weekly similarly has an implicit default end-day of Sunday:
Weekly() is Weekly(end_day=7).
Moments and durations¶
MIT values label particular moments in time, Duration values
measure the distance between two MITs of the same frequency.
Creating MIT instances¶
Pythonic equivalents of Julia's 2020Q1 / 2020M3 / 2020Y literal
suffixes are the constructor functions qq / mm / yy. The arguments
are (year, period) (or just (year,) for yy).
For half-yearly the same pattern works through the lower-level
MIT.from_yp factory because there's no dedicated hh() shorthand:
For variant end-months, instantiate the frequency first and use
MIT.from_yp:
print(MIT.from_yp(Quarterly(end_month=2), 2022, 1))
print(MIT.from_yp(Yearly(end_month=11), 2020, 1))
For the calendar-date frequencies (Daily, BDaily, Weekly), a
date object or ISO string is required:
bdaily(...) from a date that lands on a weekend raises by default
(matching Julia's :strict bias). Pass bias= to opt in to a
specific rounding rule:
print(bdaily("2022-01-01", bias="previous")) # 2021-12-31
print(bdaily("2022-01-01", bias="next")) # 2022-01-03
print(bdaily("2022-01-01", bias="nearest")) # 2021-12-31
The bias for all implicit weekend-to-business-day rounding can be flipped globally via §17 Options.
Arithmetic with time¶
MIT - MIT → Duration of the shared frequency. We can add or subtract
a Duration to an MIT to get another MIT, add and subtract two
Durations freely, but adding two MITs is a type error — the
operation has no economic meaning.
A plain int on either side of + / - is automatically treated as a
Duration in the MIT's own frequency. This is the same Julia
shorthand 2000Q1 + 6:
print(qq(2000, 1) + Duration(Quarterly(), 6)) # explicit
print(qq(2000, 1) + 6) # same thing, idiomatic
Mixing frequencies raises:
Multiplication of an MIT by an integer is not allowed:
Other operations¶
frequency_of(...) returns the frequency of its argument; ppy(...)
gives the number of periods per year; year(...) / period(...) /
mit2yp(...) extract calendar coordinates.
print(frequency_of(yy(2000)))
print(frequency_of(qq(2020, 1) - qq(2019, 3)))
t = qq(2020, 3)
print("ppy:", ppy(t.frequency)) # ppy takes a Frequency, not an MIT
print("year:", year(t))
print("period:", period(t))
print("mit2yp:", mit2yp(t))
Note that ppy accepts a Frequency (not an MIT) — pass
t.frequency rather than t. As in Julia, the returned value is the
hardcoded sentinel (52 / 365 / 260) for Weekly / Daily / BDaily
regardless of the actual year, and year / period / mit2yp are
not defined for Weekly because a week can straddle a year boundary.
Julia ↔ Python
Corresponds to Frequency and Time upstream. The most visible
idiom difference is the absence of 2020Q1 literal sugar — we
chose constructor functions (qq(2020, 1)) over operator-overloading
Python's int because operator-overload sugar has no precedent in
the scientific Python stack and would be a discoverability footgun.
Equality between an MIT and a plain integer is not supported in
Python (would violate the __eq__/__hash__ contract); use
int(mit) to extract the underlying value.
2. Ranges¶
MITRange(start, stop) is the equivalent of Julia's 2000M1:2001M9
unit-step range, with a step= keyword for non-unit strides. Both
bounds are inclusive (same as Julia). All standard collection
operations work: len, iteration, indexing, slicing, reversed.
rng = MITRange(mm(2000, 1), mm(2001, 9))
print(rng)
print("len:", len(rng))
print("first:", rng.first())
print("last:", rng.last())
print("rng[3:5]:", rng[3:5])
Julia's broadcast addition rng .+ 6 (shift the whole range by 6
periods) does not have a + operator overload here — express the
shift directly on the endpoints:
For step ranges, pass step=. The step is a nonzero integer in the
range's own frequency.
A negative step walks the range backward, mirroring Julia's
10U:-1:1U (StepRange{MIT} form). This is the natural way to write a
backcasting recurrence — see § Recursive assignments below — and is
also how reversed iteration order survives into downstream consumers
like rec and indexing without needing a
separate "is this iteration reversed?" flag.
For one-shot iteration in reverse without changing the range's identity,
wrap with reversed:
Calendar-date ranges work the same way; for BDaily, opt-in bias=
on each endpoint to round into the range when a weekend is supplied:
print(MITRange(daily("2022-01-01"), daily("2022-01-31")))
print(MITRange(bdaily("2022-01-01", bias="next"),
bdaily("2022-01-31", bias="previous")))
Julia ↔ Python
Corresponds to Ranges upstream. Julia's bd"2022-01-01:2022-01-31"
string-macro syntax has no Python analogue, but the same semantics
survive: explicit bias= per endpoint replaces the macro's implicit
"round into the range" behaviour. Julia's rng .+ 6 is not an
operator overload here — write MITRange(rng.start + 6, rng.stop + 6)
explicitly.
3. TSeries — Creation¶
TSeries is the workhorse 1-D time-series type — a NumPy ndarray
wearing an MIT-indexed labelled axis. It implements
__array_ufunc__ and __array_function__, so every NumPy operation
flows through transparently while keeping the time-axis alignment
honest (see the TSeries protocols design note).
The basic constructor takes a starting MIT and a 1-D array of values:
Important caveat — buffer aliasing on construction
TSeries(qq(2020, 1), vals) does not copy the underlying
array — vals and the new TSeries share storage, and every
modification to one is immediately reflected in the other. This
matches both the Julia upstream and xarray's DataArray
(which the protocol design follows; see
decision 16).
To break the alias, pass copy=True at construction time, or
call .copy() after the fact:
ts = TSeries(qq(2020, 1), vals, copy=True) # explicit at construct time
ts = TSeries(qq(2020, 1), vals).copy() # equivalent
ts = TSeries(qq(2020, 1), vals.copy()) # equivalent (extra allocation)
For container types — Workspace, MVTSeries — a shallow .copy()
still shares value references; reach for copy.deepcopy(...) (or
the chainable .copy(deep=True)) when you want a full break.
You can also construct a TSeries from a range alone. Without a value
argument the storage is NaN-filled (Julia's is uninitialised); pass a
scalar or an initialiser function to fill it:
rng = MITRange(qq(2020, 1), qq(2021, 4))
print(TSeries(rng)) # NaN-filled
print(TSeries(rng, math.pi)) # scalar fill
print(TSeries(rng, np.zeros)) # callable form (Julia idiom)
print(TSeries(rng, np.ones)) # likewise
print(TSeries(rng, rng_np.random)) # any callable `fn(length) -> ndarray`
print(TSeries.zeros(rng)) # classmethod shortcut for `np.zeros`
print(TSeries.ones(rng)) # likewise for `np.ones`
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : nan
2020Q2 : nan
2020Q3 : nan
2020Q4 : nan
2021Q1 : nan
2021Q2 : nan
2021Q3 : nan
2021Q4 : nan
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 3.141592653589793
2020Q2 : 3.141592653589793
2020Q3 : 3.141592653589793
2020Q4 : 3.141592653589793
2021Q1 : 3.141592653589793
2021Q2 : 3.141592653589793
2021Q3 : 3.141592653589793
2021Q4 : 3.141592653589793
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 0.0
2020Q2 : 0.0
2020Q3 : 0.0
2020Q4 : 0.0
2021Q1 : 0.0
2021Q2 : 0.0
2021Q3 : 0.0
2021Q4 : 0.0
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 1.0
2020Q2 : 1.0
2020Q3 : 1.0
2020Q4 : 1.0
2021Q1 : 1.0
2021Q2 : 1.0
2021Q3 : 1.0
2021Q4 : 1.0
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 0.09406169932734132
2020Q2 : 0.5361386083925023
2020Q3 : 0.9545476244897134
2020Q4 : 0.805132501920268
2021Q1 : 0.6221884827608385
2021Q2 : 0.8733375920081144
2021Q3 : 0.5961344024247559
2021Q4 : 0.47968491554896875
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 0.0
2020Q2 : 0.0
2020Q3 : 0.0
2020Q4 : 0.0
2021Q1 : 0.0
2021Q2 : 0.0
2021Q3 : 0.0
2021Q4 : 0.0
8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 1.0
2020Q2 : 1.0
2020Q3 : 1.0
2020Q4 : 1.0
2021Q1 : 1.0
2021Q2 : 1.0
2021Q3 : 1.0
2021Q4 : 1.0
Element-type caveat
TSeries(rng, 0) infers int64. If you'll go on to do arithmetic
that produces NaN (any frequency-conversion or aligned operator),
pick TSeries.zeros(rng) (which forces float64) or pass dtype=
explicitly. Same caveat applies upstream: TSeries(rng, 0) is
Int, TSeries(rng, zeros) is Float64.
TSeries.similar(...) and .copy() make new instances. similar
returns a NaN-filled TSeries with the same range / dtype (or a
specified range), copy returns an exact value-for-value duplicate:
similar: 8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : nan
2020Q2 : nan
2020Q3 : nan
2020Q4 : nan
2021Q1 : nan
2021Q2 : nan
2021Q3 : nan
2021Q4 : nan
copy: 8-element TSeries{Quarterly} with range 2020Q1:2021Q4:
2020Q1 : 2.7
2020Q2 : 2.7
2020Q3 : 2.7
2020Q4 : 2.7
2021Q1 : 2.7
2021Q2 : 2.7
2021Q3 : 2.7
2021Q4 : 2.7
Julia ↔ Python
Corresponds to Creation of TSeries upstream. The Julia
function-as-initialiser idiom TSeries(rng, rand) / TSeries(rng, zeros)
ports line-by-line: TSeries(rng, rng_np.random) (any callable that
takes a length and returns an ndarray) / TSeries(rng, np.zeros).
Behind the scenes the constructor invokes fn(len(rng)) and validates
the result is a 1-D ndarray of matching length — a ValueError names
the offending value if not. The wrap-vs-copy semantics are deliberately
the same as Julia and xarray (and extend to the callable's returned
buffer); see decision 16 for
the rationale.
4. TSeries — Access¶
Reading¶
Indexing by MIT returns a scalar; indexing by MITRange returns a
new TSeries; indexing by integer or slice falls through to NumPy
semantics on the underlying buffer.
rng = MITRange(qq(2000, 1), qq(2001, 1))
t = TSeries(rng, rng_np.random(len(rng)))
print("t:", t)
print("t[qq(2000,1)]:", t[qq(2000, 1)])
print("t[qq(2000,2):qq(2000,4)]:", t[MITRange(qq(2000, 2), qq(2000, 4))])
print("t[1]:", t[1])
print("t[2:4]:", t[2:4]) # ndarray slice (loses date labels)
t: 5-element TSeries{Quarterly} with range 2000Q1:2001Q1:
2000Q1 : 0.7311551900667536
2000Q2 : 0.9921813660891006
2000Q3 : 0.3050277090556356
2000Q4 : 0.870607853238372
2001Q1 : 0.6928811509356263
t[qq(2000,1)]: 0.7311551900667536
t[qq(2000,2):qq(2000,4)]: 3-element TSeries{Quarterly} with range 2000Q2:2000Q4:
2000Q2 : 0.9921813660891006
2000Q3 : 0.3050277090556356
2000Q4 : 0.870607853238372
t[1]: 0.9921813660891006
t[2:4]: [0.30502771 0.87060785]
Out-of-range reads raise:
try:
t[qq(1999, 1)]
except IndexError as exc:
print("IndexError:", exc)
try:
t[MITRange(qq(2001, 1), qq(2001, 3))]
except IndexError as exc:
print("IndexError:", exc)
Python has no begin / end keyword inside [], so the "last n by
date" idiom uses the lastdate attribute explicitly:
print(t[MITRange(t.lastdate - 2, t.lastdate)]) # last 3
print(t[MITRange(t.firstdate + 1, t.lastdate - 1)]) # drop first and last
Writing¶
Indexed assignment mutates a single position. Range assignment writes multiple positions; the right-hand side must size-match, or be a scalar broadcast.
t[qq(2000, 2)] = 5
print(t)
t[MITRange(t.firstdate, t.firstdate + 2)] = [1, 2, 3]
print(t)
t[MITRange(t.lastdate - 2, t.lastdate)] = 42 # scalar broadcast
print(t)
5-element TSeries{Quarterly} with range 2000Q1:2001Q1:
2000Q1 : 0.7311551900667536
2000Q2 : 5.0
2000Q3 : 0.3050277090556356
2000Q4 : 0.870607853238372
2001Q1 : 0.6928811509356263
5-element TSeries{Quarterly} with range 2000Q1:2001Q1:
2000Q1 : 1.0
2000Q2 : 2.0
2000Q3 : 3.0
2000Q4 : 0.870607853238372
2001Q1 : 0.6928811509356263
5-element TSeries{Quarterly} with range 2000Q1:2001Q1:
2000Q1 : 1.0
2000Q2 : 2.0
2000Q3 : 42.0
2000Q4 : 42.0
2001Q1 : 42.0
Python has no Julia-style .= vs = distinction. A scalar on the
right-hand side is broadcast to the slice. To reset the entire
TSeries to a constant, slice with [:]:
A bare t = math.pi would rebind the variable to the float
math.pi, leaving the original TSeries untouched — same as Julia.
Unlike NumPy ndarrays, TSeries resize on assignment outside the
stored range. Any gap that's neither in the old range nor the
assignment range is NaN-filled.
Resize works only for MIT-keyed assignment. An out-of-bounds
integer index still raises, matching the underlying ndarray:
Assigning a TSeries to a range copies the right-hand side's values
restricted to the assignment range:
q = TSeries(t.range, 100.0)
t[MITRange(qq(1999, 3), qq(2000, 2))] = q[MITRange(qq(1999, 3), qq(2000, 2))]
print(t)
Julia ↔ Python
Corresponds to Access to Elements of TSeries. Julia's
begin / end keywords inside [] have no Python analogue — the
semantically equivalent spellings are t.firstdate and t.lastdate.
Resize-on-MIT-assign and bounds-error-on-int-assign carry across
unchanged. The Julia t .= 42 / t[:end-2:end] .= q dot-equals
distinction collapses to plain = here because every Python
operator is already element-wise.
5. Arithmetic with TSeries¶
Two kinds of operations: whole-object (treat the series as a vector)
and element-wise (treat it as a NumPy array). In Python every
arithmetic operator and every NumPy ufunc is element-wise — there's
no + vs .+ distinction — but the alignment-by-MIT rule is the same
across both.
When two TSeries are added or subtracted, the result spans the
intersection of their ranges (anything outside is treated as
missing). Multiplying or dividing by a scalar preserves the range.
x = TSeries(MITRange(qq(2020, 1), qq(2020, 4)), rng_np.random(4))
y = TSeries(MITRange(qq(2020, 3), qq(2021, 2)), rng_np.random(4))
print("x:", x)
print("y:", y)
print("x + y:", x + y)
print("x - y:", x - y)
print("2 * y:", 2 * y)
print("y / 2:", y / 2)
x: 4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
2020Q1 : 0.19272292821385284
2020Q2 : 0.6776458827034494
2020Q3 : 0.5973620306088683
2020Q4 : 0.03762740042438173
y: 4-element TSeries{Quarterly} with range 2020Q3:2021Q2:
2020Q3 : 0.2902088850280118
2020Q4 : 0.9286285011406357
2021Q1 : 0.2856957258344023
2021Q2 : 0.30521792408319504
x + y: 2-element TSeries{Quarterly} with range 2020Q3:2020Q4:
2020Q3 : 0.8875709156368801
2020Q4 : 0.9662559015650174
x - y: 2-element TSeries{Quarterly} with range 2020Q3:2020Q4:
2020Q3 : 0.3071531455808565
2020Q4 : -0.8910011007162539
2 * y: 4-element TSeries{Quarterly} with range 2020Q3:2021Q2:
2020Q3 : 0.5804177700560236
2020Q4 : 1.8572570022812713
2021Q1 : 0.5713914516688046
2021Q2 : 0.6104358481663901
y / 2: 4-element TSeries{Quarterly} with range 2020Q3:2021Q2:
2020Q3 : 0.1451044425140059
2020Q4 : 0.46431425057031783
2021Q1 : 0.14284786291720114
2021Q2 : 0.15260896204159752
NumPy ufuncs flow through transparently. 1 + x broadcasts the scalar
across x's range; 2 / y broadcasts the reciprocal; y ** 3
cubes elementwise.
print("np.log(x):", np.log(x))
print("1 + x:", 1 + x)
print("x + y (vectorised):", x + y)
print("2 / y:", 2 / y)
print("y ** 3:", y ** 3)
np.log(x): 4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
2020Q1 : -1.6465017266551432
2020Q2 : -0.38913042438384515
2020Q3 : -0.5152319329554124
2020Q4 : -3.2800227592934807
1 + x: 4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
2020Q1 : 1.1927229282138527
2020Q2 : 1.6776458827034495
2020Q3 : 1.5973620306088683
2020Q4 : 1.0376274004243817
x + y (vectorised): 2-element TSeries{Quarterly} with range 2020Q3:2020Q4:
2020Q3 : 0.8875709156368801
2020Q4 : 0.9662559015650174
2 / y: 4-element TSeries{Quarterly} with range 2020Q3:2021Q2:
2020Q3 : 6.8915877603366775
2020Q4 : 2.153713780638218
2021Q1 : 7.000454746597292
2021Q2 : 6.5526951145072605
y ** 3: 4-element TSeries{Quarterly} with range 2020Q3:2021Q2:
2020Q3 : 0.02444173966235243
2020Q4 : 0.8008036173452537
2021Q1 : 0.02331907017889658
2021Q2 : 0.02843348562804609
In-place assignment over the intersection follows the same rule. Slicing the left-hand side with the wider range resizes the target:
z = x.copy()
common = MITRange(max(z.firstdate, y.firstdate),
min(z.lastdate, y.lastdate))
z[common] = (1 + y)[common] # write only within z ∩ y
print("z:", z)
z[y.range] = 3 + y # resize z to cover y's range
print("z (resized):", z)
z: 4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
2020Q1 : 0.19272292821385284
2020Q2 : 0.6776458827034494
2020Q3 : 1.2902088850280118
2020Q4 : 1.9286285011406357
z (resized): 6-element TSeries{Quarterly} with range 2020Q1:2021Q2:
2020Q1 : 0.19272292821385284
2020Q2 : 0.6776458827034494
2020Q3 : 3.2902088850280116
2020Q4 : 3.9286285011406354
2021Q1 : 3.2856957258344024
2021Q2 : 3.305217924083195
Julia's z .= 1 .+ y implicitly aligns the assignment to the
intersection of z and y; in Python the alignment is explicit (the
common range above). On the resize-on-assign side both languages
behave the same — the left-hand side is grown to match the assignment
range, with any pre-existing gap NaN-filled.
Mixing a TSeries and a same-length NumPy array works in either
order; the result preserves the TSeries's range:
Julia ↔ Python
Corresponds to Arithmetic with TSeries. Julia separates whole-object
arithmetic from element-wise via the . prefix; Python doesn't, so
2y becomes 2 * y and log.(x) becomes np.log(x). Range
intersection on + / - and broadcasting-on-scalar are identical.
Mechanically the implementation rides on __array_ufunc__ /
__array_function__ (xarray-style composition) rather than the
AbstractArray inheritance Julia uses; see
TSeries protocols.
6. Shifts¶
lag(x) and lead(x) shift the labels of the data (not the
data). shift(x, k) is the underlying primitive: positive k is a
lead, negative k is a lag (matches Julia). Each has an _inplace
variant that mutates in place rather than allocating.
print("x: ", x)
print("lag(x): ", lag(x))
print("lead(x): ", lead(x))
print("lag(x, 3):", lag(x, 3))
print("shift(x, -1) == lag(x):", shift(x, -1).equals(lag(x)))
x: 4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
2020Q1 : 0.19272292821385284
2020Q2 : 0.6776458827034494
2020Q3 : 0.5973620306088683
2020Q4 : 0.03762740042438173
lag(x): 4-element TSeries{Quarterly} with range 2020Q2:2021Q1:
2020Q2 : 0.19272292821385284
2020Q3 : 0.6776458827034494
2020Q4 : 0.5973620306088683
2021Q1 : 0.03762740042438173
lead(x): 4-element TSeries{Quarterly} with range 2019Q4:2020Q3:
2019Q4 : 0.19272292821385284
2020Q1 : 0.6776458827034494
2020Q2 : 0.5973620306088683
2020Q3 : 0.03762740042438173
lag(x, 3): 4-element TSeries{Quarterly} with range 2020Q4:2021Q3:
2020Q4 : 0.19272292821385284
2021Q1 : 0.6776458827034494
2021Q2 : 0.5973620306088683
2021Q3 : 0.03762740042438173
shift(x, -1) == lag(x): True
Julia ↔ Python
Corresponds to Shifts. Julia's mutation-suffix lag! / lead!
becomes the _inplace suffix — the leading-bang naming convention
has no Python tradition, and conventional Python mutators (list.sort,
dict.update) don't use one either. The sign convention on shift
is unchanged.
7. Diff and undiff¶
diff(x) defaults to k=-1 (first-difference at lag 1). Positive k
takes a lead-difference; either way the result has the same range as
x with the first |k| entries NaN-filled.
undiff(dx) is the inverse. In its plain form it's
np.cumsum-on-the-values lifted onto the same range — which means the
first value of x is lost because diff couldn't observe it.
To recover the original series exactly, anchor the inverse to a known
value at a known date via undiff(dx, anchor=(date, value)):
The same idiom works for any (MIT, value) pair, not just
x.firstdate. Pass a TSeries as the anchor instead of a tuple to
align dx over a partial overlap (see
tsecon.undiff reference).
Julia ↔ Python
Corresponds to Diff and Undiff. Julia's anchor syntax
firstdate(x) => first(x) becomes the keyword anchor=(...)
here — we collapsed Julia's Pair to a 2-tuple because Python has
no first-class pair literal. The k=-1 default and the
cumsum-equivalent baseline behaviour are unchanged.
8. Moving average¶
moving(t, n) computes the moving average over a window of length |n|.
A positive n is backwards-looking (includes lags up to n - 1), a
negative n is forwards-looking (includes leads up to |n| - 1); the
window always includes the current value. moving_sum and
moving_average are the two specialisations; moving is an alias for
moving_average. The accumulator is always float64 regardless of
the input dtype (matches Julia's zeros(out_len)).
tt = TSeries(qq(2020, 1), np.arange(1.0, 11.0))
print(tt)
print("moving(tt, -4):", moving(tt, -4)) # forwards-looking 4-window
print("moving(tt, 6):", moving(tt, 6)) # backwards-looking 6-window
10-element TSeries{Quarterly} with range 2020Q1:2022Q2:
2020Q1 : 1.0
2020Q2 : 2.0
2020Q3 : 3.0
2020Q4 : 4.0
2021Q1 : 5.0
2021Q2 : 6.0
2021Q3 : 7.0
2021Q4 : 8.0
2022Q1 : 9.0
2022Q2 : 10.0
moving(tt, -4): 7-element TSeries{Quarterly} with range 2020Q1:2021Q3:
2020Q1 : 2.5
2020Q2 : 3.5
2020Q3 : 4.5
2020Q4 : 5.5
2021Q1 : 6.5
2021Q2 : 7.5
2021Q3 : 8.5
moving(tt, 6): 5-element TSeries{Quarterly} with range 2021Q2:2022Q2:
2021Q2 : 3.5
2021Q3 : 4.5
2021Q4 : 5.5
2022Q1 : 6.5
2022Q2 : 7.5
Julia ↔ Python
Corresponds to Moving Average. The window-sign convention is the
same as Julia's. moving here is moving_average; for sums the
explicit moving_sum(t, n) is one extra import. Both overload onto
MVTSeries (per-column reduction); see
the math reference.
9. Recursive assignments¶
Time-series recurrences look like
$$ a_t = (1 - \rho) \, a_{ss} + \rho \, a_{t-1} + \varepsilon_t. $$
Julia's @rec macro rewrites the body so each iteration sees the
previously-written value at t-1. Python has no parse-time macros,
so the equivalent is a higher-order function: rec(rng, target, fn)
calls target[t] = fn(t) once per step, in order, committing each
write before the next step's fn runs.
a_ss = 1.0
rho = 0.6
a = TSeries(MITRange(qq(2020, 1), qq(2022, 1)), a_ss)
a[a.firstdate] += 0.1 # impulse
rec(rangeof(a, drop=1), a,
lambda t: (1 - rho) * a_ss + rho * a[t - 1])
print(a)
This line-by-line mirrors Julia's
@rec rangeof(a, drop=1) a[t] = (1-ρ)*a_ss + ρ*a[t-1]. The
rangeof free function
returns the stored range of a TSeries / MVTSeries / Workspace; the
drop=n kwarg skips the first n periods (or last n if n is
negative), which is the canonical recurrence-range idiom because the
right-hand side reads a[t-1] and the first such read needs t-1 to be
in range.
For the linear-recurrence common case — AR(p), Fibonacci, arbitrary
lag polynomials — there's a closed-form sibling rec_linear(target,
coeffs, lags, rng) that bypasses the per-step Python lambda call.
When the compiled Cython kernel is available (typical pip install),
rec_linear is roughly two orders of magnitude faster than rec for
the same recurrence (see the
Cython strategy design note). Same
AR(1) recurrence rewritten in closed form:
b = TSeries(MITRange(qq(2020, 1), qq(2022, 1)), a_ss)
b[b.firstdate] += 0.1
# b[t] = (1 - rho) * a_ss + rho * b[t - 1]
# = c0 + rho * b[t - 1] where c0 = (1 - rho) * a_ss
# rec_linear handles the rho * b[t - 1] part; the additive constant
# gets folded in as a coefficient with lag 0 — but rec_linear requires
# lags >= 1, so for AR(1)-with-constant we typically subtract the
# steady state, run the homogeneous recurrence, and add back.
deviation = b - a_ss
rec_linear(deviation, [rho], [1], rangeof(deviation, drop=1))
b = deviation + a_ss
print(b)
print("rec vs rec_linear match:", a.allclose(b))
print("rec_linear is using Cython:", tsecon.rec_linear_is_cython())
The drop=n kwarg is symmetric — positive n skips at the start,
negative n skips at the end. The three forms most often seen in
recurrence code:
print("rangeof(a): ", rangeof(a)) # full range
print("rangeof(a, drop=1): ", rangeof(a, drop=1)) # skip first
print("rangeof(a, drop=-1):", rangeof(a, drop=-1)) # skip last
rangeof also dispatches on MVTSeries (same return shape), MIT
(returns a one-element range), MITRange (identity + drop=), and
Workspace (intersection of all member ranges, or method="union" for
the span); see the API reference.
Backcasting (reversed range)¶
A backcast runs the same recurrence machinery backward in time — the
classic case is "we know the terminal value, what was the path that led
to it." In Julia this reads @rec t=10U:-1:1U s[t] = s[t+1] - g. The
Python port works by feeding rec a reversed MITRange (step=-1); no
new entry point is needed because rec iterates for t in rng and
MITRange carries the direction:
# Backcast: anchor `a_ss` at the end, walk backward applying
# s[t] = s[t+1] - g for a constant drift g.
g = 0.05
back = TSeries(MITRange(qq(2020, 1), qq(2022, 4)), 0.0)
back[back.lastdate] = a_ss
rec(MITRange(back.lastdate - 1, back.firstdate, step=-1), back,
lambda t: back[t + 1] - g)
print(back)
12-element TSeries{Quarterly} with range 2020Q1:2022Q4:
2020Q1 : 0.4499999999999996
2020Q2 : 0.4999999999999996
2020Q3 : 0.5499999999999996
2020Q4 : 0.5999999999999996
2021Q1 : 0.6499999999999997
2021Q2 : 0.6999999999999997
2021Q3 : 0.7499999999999998
2021Q4 : 0.7999999999999998
2022Q1 : 0.8499999999999999
2022Q2 : 0.8999999999999999
2022Q3 : 0.95
2022Q4 : 1.0
The same idiom works for rec_linear with negative lags: a reversed
range plus same-sign lags reads "already-written" future positions and
backfills the series. See the
rec_linear docstring for the
sign-of-lag-matches-sign-of-step contract.
For multi-target recurrences (two series updated together each
step), write the explicit for t in rng: loop — rec only handles
the single-target case; the wrapper buys nothing once more than one
series is being written. See the
recursive reference.
Julia ↔ Python
Corresponds to Recursive assignments. The Julia @rec macro
rewrites the body at parse time; the Python rec(rng, target, fn)
higher-order form is more verbose but matches the semantics
exactly — each iteration commits before the next runs, so
target[t - k] always reads the freshly-written value. For
performance-critical recurrences the Cython-backed rec_linear
closes the gap to Julia's @rec (see
Cython strategy for the empirical
classification).
10. Multi-variate Time Series (MVTSeries)¶
MVTSeries is a 2-D TSeries-of-TSeries — every column shares the
same frequency, the same range, and the same element type. Rows are
labelled by MIT, columns by str (Julia's symbols become plain
strings in Python).
Construction¶
Positional form: starting MIT, column names, 2-D matrix of values.
Range form: pass an MITRange and either a value initialiser (scalar,
numpy callable like np.zeros, or a 2-D ndarray) plus a name tuple:
Keyword form: each kwarg supplies one column. Values can be
TSeries, 1-D arrays, or scalars (broadcast). When firstdate_or_range
is omitted, the range is taken as rangeof_span of the kwarg series.
data = MVTSeries(
MITRange(qq(2020, 1), qq(2021, 1)),
hex=TSeries(qq(2019, 1), np.arange(1.0, 21.0)),
why=np.zeros(5),
zed=3,
)
print(data)
copy=True forces a fresh allocation in the array form; the kwarg
form always allocates fresh storage (per-column data is copied into
the wide 2-D buffer). similar(...) and .copy() work the same way
they do on TSeries.
Access¶
MVTSeries indexing has four shapes, all matching Julia's:
- Row + column with
mv[mit, name](scalar) ormv[mit_range, names](sub-MVTSeries). - Single MIT returns the whole row as a 1-D
ndarray. To get a single-rowMVTSeriesback, pass a length-1 range. - Single column with
mv[name](returns aTSeries) or with themv.nameattribute shortcut. - Tuple of column names returns an
MVTSerieswith those columns.
print("data[qq(2020, 2), 'hex']:", data[qq(2020, 2), "hex"])
print("data[qq(2020, 2)]: ", data[qq(2020, 2)]) # ndarray row
print("data[qq(2020, 2):qq(2020, 2)]:")
print(data[MITRange(qq(2020, 2), qq(2020, 2))]) # row as MVTSeries
print("data['zed']:", data["zed"]) # column as TSeries
print("data[('zed',)]:")
print(data[("zed",)]) # tuple → 1-column MVTSeries
print("data.zed (attribute access):", data.zed)
data[qq(2020, 2), 'hex']: 6.0
data[qq(2020, 2)]: [6. 0. 3.]
data[qq(2020, 2):qq(2020, 2)]:
1×3 MVTSeries{Quarterly} with range 2020Q2:2020Q2 and variables (hex,why,…):
hex why zed
2020Q2 : 6 0 3
data['zed']: 5-element TSeries{Quarterly} with range 2020Q1:2021Q1:
2020Q1 : 3.0
2020Q2 : 3.0
2020Q3 : 3.0
2020Q4 : 3.0
2021Q1 : 3.0
data[('zed',)]:
5×1 MVTSeries{Quarterly} with range 2020Q1:2021Q1 and variables (zed):
zed
2020Q1 : 3
2020Q2 : 3
2020Q3 : 3
2020Q4 : 3
2021Q1 : 3
data.zed (attribute access): 5-element TSeries{Quarterly} with range 2020Q1:2021Q1:
2020Q1 : 3.0
2020Q2 : 3.0
2020Q3 : 3.0
2020Q4 : 3.0
2021Q1 : 3.0
Iterating columns¶
To loop over (name, TSeries) pairs, use the .columns accessor —
it returns a dict[str, TSeries] whose values are views on the
underlying 2-D buffer (no copy):
The for name in data form iterates rows (NumPy semantics on a 2-D
array). For column iteration always go through .columns.
When you just want a per-column or per-row summary as a value (rather
than printing inside a loop), reach for the axis= kwarg on the
statistics reductions — mean / std / var / median / quantile
all accept it:
# Per-column means → single-row MVTSeries with the same column names.
print(mean(data, axis=0))
# Per-row means → 1-D TSeries indexed by data.range.
print(mean(data, axis=1))
Julia ↔ Python
Julia's Statistics.mean(mvts; dims=1) uses the dims= keyword
(1 = per-column, 2 = per-row); Python uses NumPy's axis=
(0 = per-column, 1 = per-row). Behaviour matches: per-column
returns a single-row MVTSeries, per-row returns a 1-D TSeries.
Julia ↔ Python
Corresponds to Multi-variate Time Series. Julia's column names
are Symbols (:hex); Python's are plain str ("hex"). The
Julia columns(data) helper becomes the data.columns accessor,
which returns a dict[str, TSeries] rather than a Julia
Generator — same use case, more Pythonic ergonomics for the
common for name, series in data.columns.items() loop. The
storage layout (single 2-D buffer + cached column views) is the
same.
11. Plotting¶
tsecon.plot(...) is a thin adapter over matplotlib (default) and
plotly. The TSeries arm overlays all inputs on a single axes; the
MVTSeries arm builds a panel grid (one subplot per variable, sharing a
legend across datasets). Backend selection is lazy: neither
matplotlib nor plotly is a hard dependency — install with
pip install 'TimeSeriesEconPy[matplotlib]' (or [plotly]). On this
page we use matplotlib because the build needs static PNG output.
rand_q = TSeries(a.range, 1 + 0.1 * rng_np.random(len(a)))
fig = plot(a, rand_q, label=["a", "rand"])
_show(fig, alt="TSeries overlay")
Mixed-frequency overlay
Our plot(...) adapter requires all overlaid series to share a
frequency — the underlying matplotlib / plotly axis is one or the
other (numeric YP coordinates or native datetime), not both. The
Julia upstream's Plots.plot(...) converts every MIT to a float
via float(2020Q1) == 2020.0, which gives mixed-frequency overlays
for free but loses unit information on the axis. We'll keep an eye
on whether this is worth the trade-off after the first round of
user feedback.
For an MVTSeries, each variable goes in its own subplot. Layout
defaults follow the Julia recipe: 1→(1,1), 2→(1,2), …, 10→(5,2).
db_a = MVTSeries(
MITRange(qq(2020, 1), qq(2023, 4)),
x=0.5,
y=np.arange(0, 16) / 15.0,
z=rng_np.random(16),
)
fig = plot(db_a, label="db_a")
_show(fig, alt="MVTSeries panel grid")
You can plot several MVTSeries together. The union of column names
determines the panel set; a dataset missing a variable is simply
skipped in that subplot.
db_b = MVTSeries(
MITRange(qq(2020, 1), qq(2023, 4)),
y=0.5,
z=np.linspace(1.0, 0.0, 16),
w=rng_np.random(16),
)
fig = plot(db_a, db_b, label=["db_a", "db_b"])
_show(fig, alt="Multi-MVTSeries overlay panel grid")
vars= selects and orders the panels (cap of 10 variables). trange=
restricts the rendered window. Every other matplotlib keyword (e.g.
figsize=) passes through.
fig = plot(
db_a, db_b,
label=["db_a", "db_b"],
vars=["y", "z"],
trange=MITRange(qq(2020, 3), qq(2022, 3)),
figsize=(7, 4),
)
_show(fig, alt="Plot with vars= and trange=")
Julia ↔ Python
Corresponds to Plotting. The Julia upstream is a Plots.jl recipe
that picks a default panel layout; here it's a matplotlib /
plotly dispatcher with the same panel-shape table. trange=
survives unchanged because it operates on the time axis directly;
Julia's xlim=(MIT, MIT) (which converts MITs to floats) does not
have a Python equivalent — when frequencies differ across panels
we currently fall back to matplotlib's native xlim= on the
underlying datetime axis. See decision 06
for the broader plotting strategy.
12. Workspaces¶
Workspace is the heterogeneous-bag-of-things container — ranges,
scalars, time series of any frequency, nested workspaces. Internally
it's an order-preserving dict[str, Any] with attribute access; most
dict operations work directly.
w = Workspace()
w.rng = a.range # the AR(1) impulse-response from §9
w.start = w.rng.start
w.a = a.copy() # own the values, don't alias
print(w)
Use del to drop a member:
The kwargs constructor mirrors Julia's name-value-pair list — useful when the workspace is being built up from a known schema:
print(Workspace(
rng=MITRange(qq(2020, 1), qq(2021, 4)),
alpha=0.1,
v=TSeries(qq(2020, 1), rng_np.random(6)),
))
A dict (or any mapping) can be passed positionally too:
datalist = {
"rng": MITRange(qq(2020, 1), qq(2021, 4)),
"alpha": 0.1,
"v": TSeries(qq(2020, 1), rng_np.random(6)),
}
print(Workspace(datalist))
To turn an MVTSeries into a Workspace, pass its columns dict
through the same constructor. The TSeries values are aliased into the
new workspace by default — pass copy.deepcopy if you want them
detached.
import copy
w_a = Workspace(db_a.columns) # aliased columns (fast)
w_a_owned = Workspace(copy.deepcopy(dict(db_a.columns))) # owned copies
print(w_a)
To go the other direction (Workspace → MVTSeries), construct an
MVTSeries from w_a.rangeof() and the dict of TSeries:
16×3 MVTSeries{Quarterly} with range 2020Q1:2023Q4 and variables (x,y,z):
x y z
2020Q1 : 0.5 0 0.9293
2020Q2 : 0.5 0.06667 0.9211
2020Q3 : 0.5 0.1333 0.5381
2020Q4 : 0.5 0.2 0.3504
2021Q1 : 0.5 0.2667 0.1494
2021Q2 : 0.5 0.3333 0.8368
2021Q3 : 0.5 0.4 0.3498
2021Q4 : 0.5 0.4667 0.1092
2022Q1 : 0.5 0.5333 0.4418
2022Q2 : 0.5 0.6 0.09418
2022Q3 : 0.5 0.6667 0.04086
2022Q4 : 0.5 0.7333 0.8006
2023Q1 : 0.5 0.8 0.6273
2023Q2 : 0.5 0.8667 0.5995
2023Q3 : 0.5 0.9333 0.3575
2023Q4 : 0.5 1 0.1845
Workspace.rangeof() returns the intersection of the ranges of all
TSeries members; Workspace.rangeof_span() returns the union.
Members that aren't TSeries are ignored for both.
Julia ↔ Python
Corresponds to Workspaces. Julia's :symbol member names are
str here. delete!(w, :start) becomes del w.start. Workspace's
.copy(deep=False) follows xarray's wrap-vs-copy contract: a
shallow copy shares value references, a deep copy doesn't (see
decision 16). The pandas / polars
interop layer attaches Workspace.to_pandas(...) etc. as method
delegates — see the interop reference.
13. MVTSeries vs Workspace¶
The two types overlap deliberately. Both store named time-series
data, both support attribute access (db.x) and item access
(db["x"]).
MVTSeriesis a matrix. All variables share a frequency, a range, and an element type. The 2-D.valuesbuffer is contiguous in memory, which makes linear-algebra and statistics calls cheap —mean(mvts)reduces over the flat buffer in a single C call,mvts @ Aworks because__array__is wired to the 2-D ndarray. Adding or removing a column requires a fresh allocation.Workspaceis a dictionary. It can hold heterogeneous types, time series of different frequencies, nested workspaces, plain scalars, ranges — anything. You can grow or shrink it in place. Linear algebra on the workspace is not defined (you'd have to pick out the relevant TSeries first).
The right call is usually clear from the data: if every value is a
TSeries of the same frequency and you'll be doing column-wise
reductions on it, reach for MVTSeries; otherwise Workspace.
The two convert into each other freely. See §12 for the round-trip pattern.
Julia ↔ Python
Corresponds to MVTSeries vs Workspace. The trade-off is the
same: MVTSeries for stats / linear-algebra, Workspace for
heterogeneous bags. One small Python-specific note: Python's
dict preserves insertion order natively (since 3.7), so
Workspace doesn't need an OrderedDict — see
the JSON serialization design note
for how that order is preserved on round-trip.
14. overlay¶
overlay has two modes. When all inputs are TSeries, the result is a
new TSeries whose range is the union of the inputs and each position
holds the first non-NaN value found left-to-right. (Out-of-range
positions count as missing too — they fall through to whichever later
input covers them.)
x1 = TSeries(MITRange(qq(2020, 1), qq(2020, 4)), 1.0)
x1[MITRange(qq(2020, 2), qq(2020, 3))] = np.nan
x2 = TSeries(MITRange(qq(2019, 3), qq(2020, 2)), 2.0)
x2[MITRange(qq(2019, 4), qq(2020, 1))] = np.nan
x3 = TSeries(MITRange(qq(2020, 2), qq(2021, 1)), 3.0)
print(overlay(x1, x2, x3))
To force a specific output range, pass rng=:
When the inputs are Workspace / MVTSeries, the result is a new
Workspace and each named member is overlaid recursively. Variables
present in multiple inputs are themselves overlaid; variables present
in only one input are taken from there.
w1 = Workspace(x=x1, a=1)
w2 = MVTSeries(qq(2020, 1), ["x", "b"], np.array([[2.0, 99.0], [2.0, 99.0]]))
w3 = Workspace(x=x3, a=3, b=3, c=3)
print(overlay(w1, w2, w3))
In the example above, x is a TSeries in all three inputs so it's
overlaid; a is a scalar taken from w1 (the leftmost input that
holds it); b is a TSeries (the b column of w2) and a scalar
in w3, so the leftmost — w2.b — wins; and c is taken from w3
since it's missing from the other two.
Julia ↔ Python
Corresponds to overlay. Identical kwarg surface and dispatch
rules — the recursive LikeWorkspace form treats Workspace /
MVTSeries / Mapping interchangeably (Julia's LikeWorkspace
union becomes (Workspace, MVTSeries, collections.abc.Mapping)
here). Forced output range is the rng= keyword instead of
Julia's positional first argument.
15. compare / @compare¶
compare walks two values recursively and returns a
CompareResult. The result is truthy iff
the inputs compared as equal; programmatically, result.differences
exposes one entry per differing leaf so callers can introspect the
diff without parsing stdout.
import copy
v1 = Workspace(
x=3,
y=TSeries(qq(2020, 1), np.ones(10)),
z=MVTSeries(qq(2020, 1), ["a", "b"], np.zeros((6, 2))),
)
v2 = copy.deepcopy(v1) # always deepcopy a nested Workspace
v2.y[qq(2020, 3)] += 1e-7
v2.z.a[qq(2020, 3)] += 0.001
v2.b = "Hello" # present in v2, missing in v1
print(compare(v1, v2))
The numeric kwargs forward to numpy.allclose /
numpy.isclose.
Bumping atol lets the small y[2020Q3] perturbation pass:
NaN-vs-NaN is not equal by default; pass nans=True to match
Julia's isapprox(...; nans=true):
n1 = Workspace(t=TSeries(qq(2020, 1), [1.0, float("nan")]))
n2 = copy.deepcopy(n1)
print("default:", bool(compare(n1, n2, quiet=True)))
print("nans=True:", bool(compare(n1, n2, nans=True, quiet=True)))
ignoremissing=True skips keys present in only one side; showequal=True
reports same-valued leaves too. They compose naturally:
To suppress the printed diff (e.g. inside a test runner), pass
quiet=True — the structured CompareResult.differences is populated
either way:
result = compare(v1, v2, quiet=True)
print("equal:", result.equal)
for d in result.differences[:3]:
print(" ", d.path, "→", d.message)
Julia ↔ Python
Corresponds to compare and @compare. Julia's @compare
macro folds into the same function here — it existed only to
capture the input variable names for the printed diff, which
Python users can supply explicitly via left= / right= kwargs.
The kwarg surface (atol, rtol, nans, showequal,
ignoremissing, quiet) is name-for-name. The Python return
type is a structured CompareResult rather than a bare Bool:
truthy/str use matches the Julia behaviour, and
result.differences carries the diff programmatically for
callers that want to inspect rather than re-parse stdout.
15a. reindex (label-shift)¶
reindex(x, (old, new)) shifts every MIT-keyed position in x so that
old becomes new; the underlying values are preserved. It dispatches
over MIT, MITRange, TSeries, MVTSeries, and Workspace. Common
use: re-anchor a model output to a different start date without
re-running the recursion.
mu = qq(2020, 1)
nu = MIT(Unit(), 1)
print("MIT :", reindex(qq(2022, 4), (mu, nu))) # 12U
print("MITRange :", reindex(MITRange(qq(2021, 1), qq(2022, 4)), (mu, nu))) # 5U:12U
ts = TSeries(qq(2021, 1), [1.0, 2.0, 3.0])
print("TSeries :", reindex(ts, (mu, nu))) # firstdate 5U
For Workspace, only members matching old.frequency are reindexed;
others (different-frequency series, scalars, strings) pass through
untouched, and nested workspaces recurse.
w = Workspace(
a=TSeries(qq(2020, 1), [1.0, 2.0]),
b=TSeries(qq(2021, 1), [10.0, 20.0]),
constant=42,
)
print(reindex(w, (qq(2021, 1), MIT(Unit(), 1))))
The copy= kwarg follows the wrap-vs-copy contract from
decision 16: by default the
returned TSeries / MVTSeries aliases the original .values buffer
(zero-allocation); pass copy=True to force an independent allocation.
Julia ↔ Python
Corresponds to reindex. Julia's Pair{<:MIT,<:MIT} becomes
a Python (old_mit, new_mit) 2-tuple. Same dispatch over MIT /
UnitRange / TSeries / MVTSeries / Workspace; same copy=
semantics (wrap by default).
16. BDaily holidays¶
The holidays kwargs (skip_all_nans=, skip_holidays=,
holidays_map=) are wired into shift / lag / lead / diff /
pct, into fconvert lower-frequency aggregation paths, and into the
_stats.py reductions (mean / std / var / etc.). A holidays map
is a TSeries[BDaily, bool] — True for business days, False for
holidays — installed once into the process via set_holidays_map(...)
and consulted by any function called with skip_holidays=True.
Loading a calendar by country / subdivision¶
set_holidays_map("DK") or set_holidays_map("CA", "ON") fetches the
named calendar from the
holidays PyPI package — install
the optional extra first with
pip install "TimeSeriesEconPy[holidays]". The map spans
bdaily("1970-01-01") to bdaily("2049-12-31"), matching the Julia
upstream's default coverage.
from tsecon import (
bdaily,
clear_holidays_map,
get_holidays_map,
get_holidays_options,
set_holidays_map,
)
print("first 6 supported countries:", get_holidays_options()[:6])
print("CA subdivisions (first 6):", get_holidays_options("CA")[:6])
set_holidays_map("CA", "ON") # Ontario, Canada
m = get_holidays_map()
print(f"map spans {m.firstdate} to {m.lastdate}, dtype={m.values.dtype}")
print("Family Day (2024-02-19) is a holiday in ON:",
not bool(m[bdaily("2024-02-19")]))
clear_holidays_map()
The country / subdivision codes follow python-holidays's
conventions (ISO 3166 alpha-2 / 3166-2). A small number of Julia's
CSV-derived names (e.g. subdivisions spelled with spaces) differ;
get_holidays_options(country) is authoritative.
Pre-built TSeries form¶
set_holidays_map(t) accepts a hand-built BDaily Boolean TSeries when
you need a calendar that doesn't match a country code (e.g. a
firm-specific working-day calendar):
cal = TSeries.trues(MITRange(bdaily("2022-01-03"), bdaily("2022-12-30")))
cal[bdaily("2022-01-03")] = False # mark New Year's observed
set_holidays_map(cal)
print("map length:", len(get_holidays_map()))
clear_holidays_map()
skip_all_nans semantics¶
A taste of the skip_all_nans knob in action — non-NaN handling is
unchanged from upstream:
ts = TSeries(bdaily("2022-01-03"), np.arange(1.0, 11.0))
ts[bdaily("2022-01-07")] = np.nan
print("pct(ts):")
print(pct(ts))
print()
print("pct(ts, skip_all_nans=True):")
print(pct(ts, skip_all_nans=True))
pct(ts):
9-element TSeries{BDaily} with range 2022-01-04:2022-01-14:
2022-01-04 : 100.0
2022-01-05 : 50.0
2022-01-06 : 33.33333333333333
2022-01-07 : nan
2022-01-10 : nan
2022-01-11 : 16.666666666666664
2022-01-12 : 14.285714285714285
2022-01-13 : 12.5
2022-01-14 : 11.11111111111111
pct(ts, skip_all_nans=True):
9-element TSeries{BDaily} with range 2022-01-04:2022-01-14:
2022-01-04 : 100.0
2022-01-05 : 50.0
2022-01-06 : 33.33333333333333
2022-01-07 : nan
2022-01-10 : 50.0
2022-01-11 : 16.666666666666664
2022-01-12 : 14.285714285714285
2022-01-13 : 12.5
2022-01-14 : 11.11111111111111
When skip_all_nans=True, pct (and diff / shift) replace NaN
inputs with the nearest non-NaN value in the shift direction before
computing — so the pct for 2022-01-10 is taken relative to
2022-01-06 (the most recent non-NaN before the gap) rather than the
NaN at 2022-01-07. See tsecon.pct for the
full kwargs surface.
Julia ↔ Python
Corresponds to BDaily Holidays. The three kwargs and the
country / subdivision loader both port directly. Julia ships
bundled CSVs under TimeSeriesEcon.jl/src/holidays/; Python
delegates to the upstream python-holidays package as the single
source of truth — no vendored data. Country / subdivision codes
follow ISO 3166 conventions rather than the Julia CSVs'
space-separated forms; use get_holidays_options(country) to
discover the supported codes.
17. Options¶
A small process-global dictionary holds the package's settings.
Three options exist today: bdaily_creation_bias, bdaily_holidays_map,
and x13path (the last is M2 plumbing; ignore for now).
Setting bdaily_creation_bias changes the default bias= for every
bdaily(...) constructor call that omits it. The default is
"strict", which raises when the input date lands on a weekend; the
other valid values are "previous", "next", "nearest".
print(bdaily("2022-01-01", bias="next")) # explicit per-call → 2022-01-03
tsecon.setoption("bdaily_creation_bias", "next")
print(bdaily("2022-01-01")) # now the default does the same
tsecon.setoption("bdaily_creation_bias", "strict") # restore
For test-friendly scoped overrides, use the option_scope(**kwargs)
context manager — it restores the prior values on exit:
from tsecon import option_scope
with option_scope(bdaily_creation_bias="previous"):
print("inside the scope:", bdaily("2022-01-01")) # → 2021-12-31
print("after the scope:", tsecon.getoption("bdaily_creation_bias"))
The bdaily_holidays_map option holds a TSeries[BDaily, bool] (or
None); True means business day, False means holiday.
set_holidays_map(...) accepts either a hand-built TSeries or a
country / subdivision code (set_holidays_map("CA", "ON")); see
§16 for the country-code form. Hand-built
example:
from tsecon import clear_holidays_map, get_holidays_map, set_holidays_map
cal = TSeries.trues(MITRange(bdaily("2022-01-03"), bdaily("2022-12-30")))
cal[bdaily("2022-01-03")] = False # mark New Year's observed
set_holidays_map(cal)
print("map length:", len(get_holidays_map()))
clear_holidays_map()
print("after clear:", get_holidays_map())
Julia ↔ Python
Corresponds to Options. Julia's setoption(:foo, value) symbol-
arg sugar becomes setoption("foo", value) with plain strings —
Python has no Symbol type and faking one would be pure friction.
The option_scope context manager is the Pythonic equivalent of
with withTimeSeriesEcon.setoption callable patterns in Julia
test suites. The country / subdivision loader is shipped (see
§16).
What's next¶
Everything in this tutorial is shipped and tested. The remaining
M4 work (linalg.jl matrix overloads, clean_old_frequencies,
@weval) does not surface in tutorial 1.
If you came from TimeSeriesEcon.jl, the
migration guide collects the
recurring idiom differences in one place. If you're starting fresh,
the reference pages cover the public
API exhaustively.