Skip to content

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
Yearly()
Yearly(end_month=3)
Quarterly()
Quarterly(end_month=2)

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.

print(type(qq(2020, 1)))
print(type(mm(2021, 5) - mm(2020, 3)))
<class 'tsecon.mit.MIT'>
<class 'tsecon.mit.Duration'>

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).

print(qq(2022, 1))
print(qq(2020, 3))
print(yy(2020))
print(mm(2022, 5))
2022Q1
2020Q3
2020Y
2022M5

For half-yearly the same pattern works through the lower-level MIT.from_yp factory because there's no dedicated hh() shorthand:

print(MIT.from_yp(HalfYearly(), 2022, 1))
print(MIT.from_yp(HalfYearly(), 2022, 2))
2022H1
2022H2

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))
2022Q1{2}
2020Y{11}

For the calendar-date frequencies (Daily, BDaily, Weekly), a date object or ISO string is required:

print(weekly("2022-01-03"))
print(bdaily("2022-01-03"))
print(daily("2022-01-03"))
2022-01-09
2022-01-03
2022-01-03

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
2021-12-31
2022-01-03
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 = qq(2001, 2) - qq(2000, 1)   # Duration of Quarterly
print(a)
5

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
2001Q3
2001Q3

Mixing frequencies raises:

try:
    qq(2000, 1) + Duration(Monthly(), 6)
except TypeError as exc:
    print("TypeError:", exc)
TypeError: Mixing frequencies not allowed: Quarterly and Monthly.

Multiplication of an MIT by an integer is not allowed:

try:
    qq(2000, 1) * 5
except TypeError as exc:
    print("TypeError:", exc)
TypeError: unsupported operand type(s) for *: 'MIT' and 'int'

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))
Yearly()
Quarterly()
ppy: 4
year: 2020
period: 3
mit2yp: (2020, 3)

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])
2000M1:2001M9
len: 21
first: 2000M1
last: 2001M9
rng[3:5]: 2000M4:2000M5

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:

print(MITRange(rng.start + 6, rng.stop + 6))
2000M7:2002M3

For step ranges, pass step=. The step is a nonzero integer in the range's own frequency.

rng2 = MITRange(mm(2000, 1), mm(2000, 8), step=2)
print(list(rng2))
[2000M1, 2000M3, 2000M5, 2000M7]

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.

back_rng = MITRange(qq(2021, 4), qq(2020, 1), step=-1)
print(list(back_rng)[:4], "...")
[2021Q4, 2021Q3, 2021Q2, 2021Q1] ...

For one-shot iteration in reverse without changing the range's identity, wrap with reversed:

print(list(reversed(MITRange(mm(2020, 1), mm(2020, 4)))))
[2020M4, 2020M3, 2020M2, 2020M1]

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")))
2022-01-01:2022-01-31
2022-01-03:2022-01-31

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:

vals = rng_np.random(5)
ts = TSeries(qq(2020, 1), vals)
print(ts)
5-element TSeries{Quarterly} with range 2020Q1:2021Q1:
  2020Q1 : 0.46887739754960267
  2020Q2 : 0.17611433527713583
  2020Q3 : 0.9376633341318071
  2020Q4 : 0.4905703497648043
  2021Q1 : 0.4323397902492737

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:

t = TSeries(rng, 2.7)
s = t.similar()
c = t.copy()
print("similar:", s)
print("copy:", c)
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)
IndexError: MIT 1999Q1 is outside the stored range 2000Q1:2001Q1.
IndexError: MITRange 2001Q1:2001Q3 is not contained in stored range 2000Q1:2001Q1.

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
3-element TSeries{Quarterly} with range 2000Q3:2001Q1:
  2000Q3 : 0.3050277090556356
  2000Q4 : 0.870607853238372
  2001Q1 : 0.6928811509356263
3-element TSeries{Quarterly} with range 2000Q2:2000Q4:
  2000Q2 : 0.9921813660891006
  2000Q3 : 0.3050277090556356
  2000Q4 : 0.870607853238372

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 [:]:

t[:] = math.pi
print(t)
5-element TSeries{Quarterly} with range 2000Q1:2001Q1:
  2000Q1 : 3.141592653589793
  2000Q2 : 3.141592653589793
  2000Q3 : 3.141592653589793
  2000Q4 : 3.141592653589793
  2001Q1 : 3.141592653589793

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.

t[MITRange(qq(1999, 1), qq(1999, 2))] = -3.7
print(t)
9-element TSeries{Quarterly} with range 1999Q1:2001Q1:
  1999Q1 : -3.7
  1999Q2 : -3.7
  1999Q3 : nan
  1999Q4 : nan
  2000Q1 : 3.141592653589793
  2000Q2 : 3.141592653589793
  2000Q3 : 3.141592653589793
  2000Q4 : 3.141592653589793
  2001Q1 : 3.141592653589793

Resize works only for MIT-keyed assignment. An out-of-bounds integer index still raises, matching the underlying ndarray:

try:
    t[15] = 3.5
except IndexError as exc:
    print("IndexError:", exc)
IndexError: index 15 is out of bounds for axis 0 with size 9

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)
9-element TSeries{Quarterly} with range 1999Q1:2001Q1:
  1999Q1 : -3.7
  1999Q2 : -3.7
  1999Q3 : 100.0
  1999Q4 : 100.0
  2000Q1 : 100.0
  2000Q2 : 100.0
  2000Q3 : 3.141592653589793
  2000Q4 : 3.141592653589793
  2001Q1 : 3.141592653589793

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:

v = 3 * np.ones(x.shape)
print(x + v)
4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
  2020Q1 : 3.1927229282138527
  2020Q2 : 3.6776458827034495
  2020Q3 : 3.5973620306088683
  2020Q4 : 3.0376274004243817

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.

dx = diff(x)
print(dx)
3-element TSeries{Quarterly} with range 2020Q2:2020Q4:
  2020Q2 : 0.48492295448959655
  2020Q3 : -0.08028385209458111
  2020Q4 : -0.5597346301844865

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.

print(undiff(dx))
4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
  2020Q1 : 0.0
  2020Q2 : 0.48492295448959655
  2020Q3 : 0.40463910239501544
  2020Q4 : -0.1550955277894711

To recover the original series exactly, anchor the inverse to a known value at a known date via undiff(dx, anchor=(date, value)):

x2 = undiff(dx, anchor=(x.firstdate, float(x[x.firstdate])))
print("x ≈ x2:", x.allclose(x2))
x ≈ x2: True

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)
9-element TSeries{Quarterly} with range 2020Q1:2022Q1:
  2020Q1 : 1.1
  2020Q2 : 1.06
  2020Q3 : 1.036
  2020Q4 : 1.0216
  2021Q1 : 1.01296
  2021Q2 : 1.007776
  2021Q3 : 1.0046656
  2021Q4 : 1.00279936
  2022Q1 : 1.001679616

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())
9-element TSeries{Quarterly} with range 2020Q1:2022Q1:
  2020Q1 : 1.1
  2020Q2 : 1.06
  2020Q3 : 1.036
  2020Q4 : 1.0216
  2021Q1 : 1.01296
  2021Q2 : 1.007776
  2021Q3 : 1.0046656
  2021Q4 : 1.00279936
  2022Q1 : 1.001679616
rec vs rec_linear match: True
rec_linear is using Cython: True

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(a):          2020Q1:2022Q1
rangeof(a, drop=1):  2020Q2:2022Q1
rangeof(a, drop=-1): 2020Q1:2021Q4

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.

mv = MVTSeries(qq(2020, 1), ("a", "b"), rng_np.random((6, 2)))
print(mv)
6×2 MVTSeries{Quarterly} with range 2020Q1:2021Q2 and variables (a,b):
              a        b
2020Q1 : 0.6193    0.418
2020Q2 : 0.9767   0.3406
2020Q3 : 0.9777   0.7075
2020Q4 : 0.4923  0.02521
2021Q1 : 0.7208   0.1814
2021Q2 : 0.5572   0.9048

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:

print(MVTSeries(MITRange(qq(2020, 1), qq(2021, 3)),
                ("one", "too", "tree"), np.zeros))
7×3 MVTSeries{Quarterly} with range 2020Q1:2021Q3 and variables (one,too,…):
         one  too  tree
2020Q1 :   0    0     0
2020Q2 :   0    0     0
2020Q3 :   0    0     0
2020Q4 :   0    0     0
2021Q1 :   0    0     0
2021Q2 :   0    0     0
2021Q3 :   0    0     0

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)
5×3 MVTSeries{Quarterly} with range 2020Q1:2021Q1 and variables (hex,why,…):
         hex  why  zed
2020Q1 :   5    0    3
2020Q2 :   6    0    3
2020Q3 :   7    0    3
2020Q4 :   8    0    3
2021Q1 :   9    0    3

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) or mv[mit_range, names] (sub-MVTSeries).
  • Single MIT returns the whole row as a 1-D ndarray. To get a single-row MVTSeries back, pass a length-1 range.
  • Single column with mv[name] (returns a TSeries) or with the mv.name attribute shortcut.
  • Tuple of column names returns an MVTSeries with 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):

for name, series in data.columns.items():
    print(f"Average of {name!r} is {mean(series):.4f}.")
Average of 'hex' is 7.0000.
Average of 'why' is 0.0000.
Average of 'zed' is 3.0000.

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))
1×3 MVTSeries{Quarterly} with range 2020Q1:2020Q1 and variables (hex,why,…):
         hex  why  zed
2020Q1 :   7    0    3
5-element TSeries{Quarterly} with range 2020Q1:2021Q1:
  2020Q1 : 2.6666666666666665
  2020Q2 : 3.0
  2020Q3 : 3.3333333333333335
  2020Q4 : 3.6666666666666665
  2021Q1 : 4.0

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")
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")
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")
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=")
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)
Workspace with 3 variables
    rng ⇒ 2020Q1:2022Q1
  start ⇒ 2020Q1
      a ⇒ 9-element TSeries{Quarterly}

Use del to drop a member:

del w.start
print(w)
Workspace with 2 variables
  rng ⇒ 2020Q1:2022Q1
    a ⇒ 9-element TSeries{Quarterly}

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)),
))
Workspace with 3 variables
    rng ⇒ 2020Q1:2021Q4
  alpha ⇒ 0.1
      v ⇒ 6-element TSeries{Quarterly}

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))
Workspace with 3 variables
    rng ⇒ 2020Q1:2021Q4
  alpha ⇒ 0.1
      v ⇒ 6-element TSeries{Quarterly}

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)
Workspace with 3 variables
  x ⇒ 16-element TSeries{Quarterly}
  y ⇒ 16-element TSeries{Quarterly}
  z ⇒ 16-element TSeries{Quarterly}

To go the other direction (Workspace → MVTSeries), construct an MVTSeries from w_a.rangeof() and the dict of TSeries:

print(MVTSeries(w_a.rangeof(), **{k: v for k, v in w_a.items() if isinstance(v, 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"]).

  • MVTSeries is a matrix. All variables share a frequency, a range, and an element type. The 2-D .values buffer 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 @ A works because __array__ is wired to the 2-D ndarray. Adding or removing a column requires a fresh allocation.
  • Workspace is 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))
7-element TSeries{Quarterly} with range 2019Q3:2021Q1:
  2019Q3 : 2.0
  2019Q4 : nan
  2020Q1 : 1.0
  2020Q2 : 2.0
  2020Q3 : 3.0
  2020Q4 : 1.0
  2021Q1 : 3.0

To force a specific output range, pass rng=:

print(overlay(x1, x2, x3, rng=MITRange(qq(2020, 1), qq(2020, 4))))
4-element TSeries{Quarterly} with range 2020Q1:2020Q4:
  2020Q1 : 1.0
  2020Q2 : 2.0
  2020Q3 : 3.0
  2020Q4 : 1.0

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))
Workspace with 4 variables
  x ⇒ 5-element TSeries{Quarterly}
  a ⇒ 1
  b ⇒ 2-element TSeries{Quarterly}
  c ⇒ 3

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))
_.y: different
_.z.a: different
_.z: different
_.b: missing in left
_: different

The numeric kwargs forward to numpy.allclose / numpy.isclose. Bumping atol lets the small y[2020Q3] perturbation pass:

print(compare(v1, v2, atol=1e-5))
_.z.a: different
_.z: different
_.b: missing in left
_: different

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)))
default: False
nans=True: True

ignoremissing=True skips keys present in only one side; showequal=True reports same-valued leaves too. They compose naturally:

print(compare(v1, v2, showequal=True, ignoremissing=True, atol=0.01))
_.x: same
_.y: same
_.z.a: same
_.z.b: same
_.z: same
_: same

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)
equal: False
  ('_', 'y') → different
  ('_', 'z', 'a') → different
  ('_', 'z') → different

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
MIT       : 12U
MITRange  : 5U:12U
TSeries   : 3-element TSeries{Unit} with range 5U:7U:
  5U : 1.0
  6U : 2.0
  7U : 3.0

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))))
Workspace with 3 variables
         a ⇒ 2-element TSeries{Unit}
         b ⇒ 2-element TSeries{Unit}
  constant ⇒ 42

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()
first 6 supported countries: ('ABW', 'AD', 'AE', 'AF', 'AFG', 'AG')
CA subdivisions (first 6): ('AB', 'BC', 'MB', 'NB', 'NL', 'NS')
map spans 1970-01-01 to 2049-12-31, dtype=bool
Family Day (2024-02-19) is a holiday in ON: True

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()
map length: 260

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).

print("default bias:", tsecon.getoption("bdaily_creation_bias"))
default bias: strict

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
2022-01-03
2022-01-03

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"))
inside the scope: 2021-12-31
after the scope: strict

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())
map length: 260
after clear: None

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.