Aligning series

nseries works on plain []float64 vectors. It has no timestamps, and its two-series methods pair values from the newest end (GLE-253). A strategy that combines series therefore has to put them on one clock before it calls nseries: a 1h primary with a FRED series, 1h with a declared 1d, or two 1h futures legs. This section sets out the rules and the strategies/kit helpers that apply them. The worked examples at the end assume a 1h primary.

The clock and the “as known at” rule

The engine merges every series of a run into one stream ordered by close time, and every bar, primary or declared, calls OnBar (Backtest event ordering). That gives one rule for every series: a decision at time t may use a value only if its bar closed at or before t (CloseTime <= t; GLE-221 specification §3.15, rules 1-5). ctx.Series(id).Close(0) already follows it: on any bar it is that series’ newest value closed by now, the value as known at the decision. kit.AsOfClose(series, cutoff) applies the same rule at an earlier cutoff.

Bars that close at the same instant follow the co-close order: other price series first, then the primary, then releases (cot:, macro:, fx:RBA:). On the primary’s bar a co-closing price leg is already current, so a spread never pairs this hour’s primary with last hour’s leg. A release stamped at the same instant is not yet visible: it is first seen at the next primary bar, because a release stamped at a bar’s close came after that close’s price.

Fresh() is true when a series’ newest bar closed at the current instant, on every OnBar call at that instant: when several bars close together, a co-closing series stays fresh through all of their calls. To act once per new value, test the delivered bar (bar.SeriesID == "cpi"), or remember the close time you last acted on, as cpigate does below. A series that is not fresh still has a value; measure how old it is with kit.AgeAt.

Each buffer counts its own bars. ctx.Series("d1").Close(3) is three daily bars back; Close(3) on a 1h primary is three bars back, which is more than three hours of clock when bars are missing. Never index one series by another series’ position.

Three patterns

PatternWhat it doesUse it forkit
Per legCompute each series on its own clock, then combine the results at the decisionindicators on each leg: a daily trend, an hourly RSInone: run nseries on ctx.Series(id).Closes()
Stretch slow onto fastCarry the slow series’ latest value onto each primary closelevels and gatesAsOfCloses(aux, prim, n), AsOfClose; declared once, as an aligned series
Sample fast on the slow clockRecord the primary’s last close at or before each releasecorrelations and regressions against a release seriesReleaseClock

Stretching repeats each slow value across many fast bars. Do not compute paired-window statistics (correlation, beta, regression) on stretched values: the repeats bias them towards zero (the Epps effect). Sample on the slow clock instead. Each kit helper’s doc comment names its pairing class: as-of (carried forward), same-clock (exact close-time match) or release clock (event-sampled).

A ReleaseClock records one pair per release. Call Observe(aux, prim) at the end of every OnBar, for every series, so a primary bar closing at exactly a release instant is paired with it whichever of the two the engine delivers first. Series() returns the recorded release values and primary closes, oldest first, ready for Correlation or a regression on their changes.

Aligned series: one row per grid bar

The kit helpers above rebuild their windows on every call, from whatever each buffer still holds. A strategy that wants all its series on one clock, so that row i of every series is the same bar, declares aligned series instead (GLE-353). An aligned series is a view the SDK maintains bar by bar: at every bar of a grid series (usually the primary, the finest series of the run) it appends one sample carrying the newest value of a source series known at that bar’s close. Between two source bars the value is repeated, so a weekly series lined up on a daily grid repeats each report on every day until the next one. The view is the declared form of “stretch slow onto fast”.

Take a strategy that trades daily ES and reads the weekly COT report and quarterly GDP. It declares the two release series as usual, then one view for each:

func (s *Panel) Lookback() int { return 21 } // the primary, and so each view on it, buffers 21 bars

func (s *Panel) DataRequirements() []algolang.DataRequirement {
    weekly := algolang.DataSchemaSpec{Kind: algolang.Bars, BarKind: algolang.BarsTime, Interval: "1w"}
    return []algolang.DataRequirement{
        {SeriesID: "cot", Symbol: "cot:CFTC:13874A:LF", Lookback: 52, Schema: weekly},
        {SeriesID: "gdp", Symbol: "macro:FRED:GDP", Lookback: 2, Schema: weekly},
    }
}

func (s *Panel) AlignedSeries() []algolang.AlignedSeries {
    return []algolang.AlignedSeries{
        {SeriesID: "cot_d", Source: "cot"}, // Grid "" is the primary
        {SeriesID: "gdp_d", Source: "gdp"},
    }
}

On every daily bar, ctx.Series("cot_d").Bar(i), ctx.Series("gdp_d").Bar(i) and the primary’s Bar(i) describe the same day, from the newest end:

prim, cotD, gdpD := ctx.Series(ctx.PrimarySymbol()), ctx.Series("cot_d"), ctx.Series("gdp_d")
n := min(prim.Len(), cotD.Len(), gdpD.Len())
for i := range n { // newest row first
    ctx.Logf("%s ES=%g COT=%g (published %s) GDP=%g", prim.Bar(i).CloseTime.Format(time.DateOnly),
        prim.Close(i), cotD.Close(i), cotD.Bar(i).SourceCloseTime.Format(time.DateOnly), gdpD.Close(i))
}

strategies/demo/go/alignpanel is the complete example; see Worked examples.

Declaring a view

SeriesAligner is an optional interface, probed like DataRequirer. Each AlignedSeries has three fields:

FieldMeaning
SeriesIDThe view’s id: read it with ctx.Series(SeriesID). It must differ from every series id in the run and from the other views.
SourceThe canonical id (SeriesID, else symbol) of the series whose values the view carries: a configured or declared series of any kind, price or release.
GridThe canonical id of the clock series. "" means the primary.

Views are built in the strategy process. The engine never sees a sample: it validates each declaration, fetches the source’s history far enough back to fill the view’s warm-up (below), and otherwise delivers bars exactly as before. The source and the grid still call OnBar for each of their own bars; a view calls nothing. With no SeriesAligner a run is byte-identical to a run before this feature.

What a sample holds

At each grid bar the view appends the newest source bar that is visible at the grid bar’s close:

  • a source bar that closed before the grid bar is visible;
  • one that closed at the same instant is visible, unless the source is a release (cot:, macro:, fx:RBA:) and the grid is a price series.
Source bar closing at the grid closeGrid is a price seriesGrid is a release series
a price barvisiblevisible
a releasenot visible: carried from the next grid barvisible

Two price bars that close together describe trading up to the same instant, so a daily bar closing at the same midnight as an hourly grid bar is in that hour’s sample. A release stamped at exactly a price bar’s close was published after that close’s price, which is GLE-259’s tie rule: it is first carried by the next grid bar. These are the rules the co-close order already applies to ctx.Series(id).Close(0) on the primary’s bar (The clock and the “as known at” rule); a view applies them to every grid, not just the primary, and whatever order the engine delivers co-closing bars in.

The ticket’s example: weekly bars that close at 17:00 New York on Friday, lined up on daily bars that close at 16:00. Friday’s daily bar closes an hour before this week’s weekly bar, so every day of the week carries last week’s:

daily close, 16:00 New York   Mon   Tue   Wed   Thu   Fri  | Mon   Tue  ...
weekly bar W closes                                  17:00 Fri
sample carries                W-1   W-1   W-1   W-1   W-1  | W     W    ...
Updates                       1     0     0     0     0    | 1     0    ...

A sample is a copy of the source bar with the grid bar’s times:

FieldOn a sample
OpenTime, CloseTimethe grid bar’s, so the view lines up index for index with its grid and Fresh() follows the grid’s clock
Open, High, Low, Close, Volumethe source bar’s (a completed bar: never a developing one)
Symbolthe source’s symbol
SeriesIDthe view’s id
Ref, Session, raw prices, CumulativeAdjustment, CumulativePriceRatio, ActiveContract, seam fieldsthe source bar’s
SourceOpenTime, SourceCloseTimethe source bar’s own times. SourceCloseTime is when its value became known: its release stamp, or a price bar’s close
Updateshow many source bars became visible since the view’s previous sample: 0 the value is carried forward, 1 a new value, 2 or more a catch-up whose intermediate values the view does not show. The view’s first sample has 1
Pathnil

The three provenance fields are zero on every bar that is not a sample.

Reading a view

  • Len(), Bar(i), Close(i) and the bulk views (Closes() and so on) work as on any series; index 0 is the current grid bar.
  • New value or repeat: Bar(0).Updates > 0 means the current grid bar carries a source bar the previous one did not. Fresh() cannot tell you: it compares the sample’s CloseTime, the grid’s, with now, so a view is fresh at every grid close.
  • Age of the information: ctx.Now().Sub(view.Bar(0).SourceCloseTime). kit.AgeAt, kit.StaleLegs and kit.StaleSeries use SourceCloseTime when it is set, so kit.StaleSeries(ctx, 10*24*time.Hour, "cot_d") reports a COT report more than ten days old, not a daily bar.
  • Grid-clocked helpers: kit.AsOfCloses, kit.JoinCloses, kit.Cadence, kit.CompareLegs and kit.AllFresh read CloseTime, so on a view they see the grid’s clock.
  • Periods: Ref passes through, so view.Bar(0).Ref names the period of the value carried (the COT as-of date, the FRED observation date). Period arithmetic (kit.PeriodsAgo, kit.RefValue) belongs on the source’s own release series, where each period has one bar.

How long a view is

A view buffers as many samples as its grid buffers bars: the grid’s declared Lookback when a requirement names it, else the global Lookback(). To hold more samples, deepen the grid. The source’s own Lookback does not limit the view. The view holds what the grid actually holds: an epoch daily grid warms up Lookback calendar days, not trading days (only session daily series widen their window), so an ES grid with Lookback() = 21 starts the run with 19 bars (18 warm-up bars and the first decision; see the worked example) and its views with as many samples. They fill as the run goes on.

Warm-up. Before the first decision, the view is built from the grid’s warm-up bars, each with the newest source bar visible at it. The source’s own warm-up covers only its own depth (a declared COT series with Lookback: 2 warms up two reports), which may not reach back to the grid’s oldest warm-up bar. The engine therefore fetches the source’s older history itself, back to the newest bar visible at the grid’s oldest held bar, and sends those bars tagged with the view’s id: they reach only that view and never the source’s own buffer, so ctx.Series("cot") is unchanged by declaring a view on it. With enough history, every view holds its grid’s depth at the first decision.

When the source starts later. A grid bar with no visible source bar gets no sample. A view is therefore shorter than its grid until the source’s first bar: a FRED series that starts in 1990 lined up on a grid that starts in 1985 holds nothing for the first five years. Every grid bar from then on gets a sample, so the view stays right-aligned with its grid: view.Bar(i) and grid.Bar(i) are the same bar for every i < view.Len(). Guard reads with Len(). When the source’s archive starts after the grid’s oldest warm-up bar, the engine records an aligned_shortfall degradation naming the view (“cot_d: aligned series holds 80 of the 104 grid bars in its warm-up …”) in the run’s degradation notes and degradation.json; it never refuses the run.

Repeats, catch-ups and gaps

  • A stall repeats the last value, with SourceCloseTime unchanged and Updates == 0. In the 2025 shutdown the E-mini COT report published on 2025-09-26 (as of 2025-09-23) was the newest until 2025-11-19, when the report as of 2025-09-30 came out: a daily view repeats the 26 September report for 54 days, which kit.StaleSeries on the view reports.
  • A catch-up of several releases between two grid bars (reports published together close a second apart) shows only the newest, with Updates counting them. The intermediate values are in the source’s own release view.
  • A missing grid bar (an hour with no trades: marketfeed never fills gaps) gets no sample. The grid is the clock: the view skips what the grid skips, and stays aligned with it.
  • A missing source period has no bar to carry. CPIAUCSL has no October 2025, so a daily CPI view carries September 2025’s value until November’s release; Ref says which month the value describes.
  • Volume and other totals repeat with the value. Never sum a view’s volume across repeats.

When to use which view

A slow series declared once has two views: its own series (the release view: one bar per release, or per source bar) and any aligned view on it.

TaskUse
A level or gate at the decision: the newest COT, the newest GDPthe aligned view’s Bar(0), or the release view’s Close(0)
A table of rows on the grid’s clock, state built bar by bar, printing a panelthe aligned view
How old the newest value is; whether this bar brought a new onethe aligned view’s SourceCloseTime and Updates
Statistics over the data’s own history: COT index, percentiles, z-scores, changes between reportsthe release view
Anything keyed by period: year on year, quarter on quarterthe release view with kit.PeriodsAgo / kit.RefValue
Correlations and regressions against the slow serieskit.ReleaseClock, or a view whose grid is the release series (below)
Two same-interval legs with missing bars, for statisticskit.JoinCloses

Do not compute paired-window statistics, averages or volatilities on an aligned view of a slower series. Its repeats weight each value by how long it stayed current and bias correlations towards zero (the Epps effect): a 20-day average of a daily COT view is not an average of reports.

A faster source on a slower grid

A view can also line a faster series up on a slower one: a daily grid sampling an hourly source carries the last hourly bar at or before each daily close. That is the last bar, not an aggregate: its High is that hour’s high, not the day’s. For daily bars built from hourly ones use kit.Resample, or declare the daily interval.

With the grid set to a release series, a view records the primary’s last close at or before each release, which is the release clock as a view:

{SeriesID: "es_at_cpi", Source: "fut:XCME:ES:U26", Grid: "cpi"}

Each sample holds the ES bar at or before one CPI release (an ES bar closing exactly at the release instant is included), and the view buffers as many samples as the CPI series buffers releases. ctx.Series("es_at_cpi").Closes() and ctx.Series("cpi").Closes() then pair release by release for ReturnCorrelation. Unlike kit.ReleaseClock, the view is full from the first decision (it is built from the warm-up) and needs no Observe call.

Within one instant

The SDK updates every view before it calls OnBar for the grid bar, so the grid bar’s own OnBar already sees that bar’s sample. When several bars close at the same instant, a co-closing source bar can be delivered after the grid bar; the view then revises its newest sample to carry it. This happens when the grid is a declared price series and the source is the primary (or a later series of the same class): at midnight the declared 1d grid bar is delivered before the primary’s hourly bar that also closes at midnight, and the daily sample is completed when the hourly bar arrives. Read views on the primary’s bar: by then every co-closing price bar has been delivered, so its samples are final for that instant. On the grid’s own OnBar, when the grid is not the primary, the newest sample can still change within the instant.

Where daily and weekly bars close

A sample is decided by close times, so the grid’s close times matter:

  • Epoch daily bars (no session) close at 00:00 UTC, so Friday’s bar closes at Saturday 00:00 UTC and carries a COT report published at 16:00 New York that Friday.
  • Epoch weekly bars (1w with no session) run from Thursday 00:00 to Thursday 00:00 UTC, because 1 January 1970 was a Thursday. A Friday COT report lined up on them is first carried at the next Thursday close, six days later. That is late, never look-ahead, but it is rarely what a strategy wants: use a session weekly grid, which closes at the end of the week’s last trading session, through a configured series with its own session or a run with --session.
  • Session daily bars close at the session end, which can tie a 16:00 or 17:00 New York release stamp: a COT report stamped at 16:00 New York ties a 16:00 equity close and is carried from the next day; the five FRED series stamped 17:00 New York tie a 17:00 futures close the same way.
  • Every stamp and close is an instant, so daylight-saving changes need no special handling.

Choosing the grid

  • "" is the primary. When the primary’s series id is not its symbol (a multi-interval config derives ids such as fut:XCME:ES:U26@1h), the engine refuses "" and names the id to write instead.
  • Any configured or declared series can be a grid, including a release series (one sample per release, above). A view cannot be a grid or a source.
  • The finest series is the natural grid for a panel: on it, a source value is skipped only when two source bars fall between the same pair of grid bars, and Updates reports it. Declaring several views on one grid gives a panel whose rows all line up.

On a continuous run

Views work on a continuous run as on any other (GLE-394), in both directions:

  • A continuous grid. On fut:XCME:ES the primary is the continuous series, and Grid: "" lines a view up with its bars. A daily continuous series reads trade-date session bars, which close at the session’s end (16:00 Chicago, 21:00 or 22:00 UTC), not at 00:00 UTC. A COT report published at 20:00 UTC on a Friday is therefore carried by that Friday’s own session bar, where an epoch daily grid carries it from the bar closing at Saturday 00:00 UTC. The grid’s warm-up is the series’ own (Lookback and the Series API), so the views are as deep as it is from the first decision.
  • A continuous source. A view’s Source can be a continuous series, configured or declared: fut:XCME:NQ known at each ES close, say. A sample is a copy of the source bar as the strategy receives it in ctx.Series(source): adjusted prices, RawPrice (the contract’s own price), ActiveContract, Session and the adjustment fields.
  • The frame of a continuous sample. Every continuous bar of a run is in one adjusted frame: the composite’s, anchored at the run’s end. That holds for the live bars, the warm-up, the source’s own series and every sample, so a view and its source agree value for value, and a roll after a sample re-adjusts nothing the strategy holds. The price known when the sample was taken is its raw price; the adjusted one is in the run’s frame, the frame every indicator on the series reads.
  • The reach-back. When the grid’s warm-up reaches past the source’s own warm-up, the engine reads the source’s composite again from earlier, pinned to the run’s snapshot token, generation and adjustment anchor. It keeps a wider read only if that reproduces the bars already held, adjusted and original alike. It never fetches the root as a plain series. When the feed cannot serve the older window, the view starts short and the run records an aligned_shortfall, as on a plain run.
  • A declared continuous series by its id. Its bars carry the declared SeriesID, as a declared plain series’ do. A view names it by that id (Source: "nq"), ctx.Series("nq") reads it, and its own Lookback sizes its buffer.

Two continuous roots on one exchange can still close a second apart. ES and NQ daily session bars end at 21:59:59 or 22:00:00 UTC, set by each root’s last trade. On 40 of 430 ES decisions from January 2025 to August 2026, NQ’s session bar closed a second after ES’s, so the view carried the previous trade date’s NQ. That is exactly what was known at the ES close. When a decision wants both roots’ same session, check view.Bar(0).Session.TradeDate against bar.Session.TradeDate, or pair the bars by trade date as strategies/demo/go/esnqspread does.

Limits and refusals

The SDK refuses at Init: an empty SeriesID or Source; a SeriesID used twice, equal to a declared series id or to the primary; a Source equal to the grid; a view used as another view’s source or grid; Grid: "" with no primary. The engine refuses, before fetching anything for the view:

  • an unknown source or grid id, and Grid: "" on a derived primary id;
  • a source that is a corporate-action-adjusted equity (eq:V:T:adj=split or adj=splitdiv; keyed on the qualifier since GLE-328): the reach-back would fetch bars older than the run’s corporate-action window (a CA equity as the grid is fine);
  • --replicate above 1;
  • live mode, until live delivery of declared series exists (GLE-295);
  • an event-bar run (below);
  • the CSV strategy wire, which cannot carry the declaration: run the strategy on stdio-pb-v1 (declaring strategies need it already). A raw CSV replay with no engine builds views from the bars it is given, without a warm-up.

Event-bar runs (volume, tick, range and the other activity bars) are refused too: views are defined over time-bar grids only.

Each view costs one sample per grid bar and memory for its grid’s depth. The reach-back adds a fetch or two per view at startup, widening backwards only until it finds the bar it needs (two views of one source fetch its history twice).

Missing bars

Marketfeed never fills gaps: an hour with no trades has no bar. Two 1h series therefore drift apart by position, and Close(5) can be a different hour on each leg. Pair same-interval legs by CloseTime:

  • Statistics: keep matching hours only. kit.JoinCloses(a, b, n) returns the last n hours present in both legs (an inner join on exact close time), as two equal-length series.
  • Levels: carry forward, with a staleness check. Carry each leg’s latest value with kit.AsOfCloses, and refuse a stale leg with kit.AgeAt, kit.StaleLegs or kit.StaleSeries(ctx, maxAge, ids...), which generalises kit.AllFresh from “closed exactly now” to “closed within maxAge”.

Daily legs can also end their sessions at different times. kit.Cadence summarises a leg’s interval and close time of day, and kit.CompareLegs(a, b, loc) reports whether two legs share both, or the offset between them: GLD closes at 16:00 New York while GC and DX close at 17:00, so GLD paired with GC by date lacks the last hour of GC’s information. Close time of day is compared only for daily-or-slower legs; two intraday legs are aligned when they share an interval.

Release-stamped series

Release-stamped series: COT, FRED and RBA has the stamping rules. What matters for lining them up:

  • Publication time, not the period. A FRED bar closes at the instant its value was first public, and Bar.Ref is the period it describes. The delay differs by series. DGS10 for 23 September 2026 closes on 24 September at 16:15 New York. August 2026 payrolls close on 4 September (the first Friday of the next month) at 08:30. CPI arrives in the middle of the next month (August 2026 CPI on 11 September at 08:30), and GDP about a month after its quarter ends (the first quarter of 2026 on 30 April).
  • Released together, a second apart. Observations published together close a second apart in observation order, so a catch-up after a shutdown arrives as consecutive bars.
  • First-release values only. FRED serves each observation’s value as first published; revisions never reach a strategy (First-release values, not revisions).
  • Estimated stamps. Before a series’ ALFRED record starts, its stamps are estimated (Estimated release dates in early FRED history).
  • Missing periods. A period that was never published has no bar: CPIAUCSL has no October 2025. Bar(k) counts releases, so key anything by period on Ref:
    • kit.PeriodsAgo(cpi, kit.PeriodMonth, 12) is year on year. On the August 2026 row it returns August 2025, and it reports ok false for a period that has no bar rather than returning a neighbour.
    • kit.RefValue(s, ref) looks up one period; kit.ShiftRef(ref, p, k) does the calendar arithmetic.
    • kit.PairByRef(a, b, n) pairs two release series by period, with the instant each pair became known: the later of the two releases.
  • Warm-up counts releases. A declared release series warms up to its Lookback in releases, not in time (How a release reaches a strategy).

Higher timeframes from base bars

There are two ways to see daily bars beside a 1h primary.

  1. Declare them (Interval: "1d"). Marketfeed stamps each daily bar at its close, and the co-close order delivers it before a primary bar that closes at the same instant.
  2. Build them from the primary’s own bars. kit.Resample(prim, kit.TimeframeDay, loc) returns the completed periods only, those whose end is at or before the newest base bar’s close, plus the developing period built from its base bars to date. The developing bar is rebuilt from base bars on every call, so a completed bar never changes. kit.HTFCloses(completed) gives nseries the closes. ISO weeks (TimeframeWeek), months and years work the same way.

A base bar belongs to the period that contains its last instant, so a daily bar stamped at the next midnight belongs to the day it covers. Periods are calendar periods in loc, not exchange trade dates: in New York, a Sunday-evening Globex bar belongs to Sunday and so to the previous ISO week. Completion is strict and needs no holiday calendar: a week completes on the first base bar that closes at or after the following Monday 00:00. kit.WeeklyOHLCCompleted instead treats Friday’s daily bar as the end of the week; with Resample, read the week to date from the developing bar.

kit.PeriodStarts(prim, tf, loc) returns one flag per bar, 1 on the first bar of a new period compared with the bar before it and 0 otherwise. A week starts at an ISO-week change, and a day, month or year at a calendar change. The flags are causal, and the oldest buffered bar is not flagged.

A higher-timeframe series must be stamped at its close. A weekly bar stamped at the Monday that starts its week would put the whole week’s prices on the week’s first base bar. kit.RequireCloseStamped(series) returns an error for a series with any bar whose close is not after its open; call it once on a declared higher-timeframe series before trusting it. The engine itself refuses only a bar that closes before it opens.

Worked examples

1h ES with CPIAUCSL. strategies/demo/go/cpigate is a complete example. make run-cpigate runs it on an hourly ES contract against the marketfeed adapter, and engine/gle265_cpigate_e2e_test.go runs it through the file-csv adapter on the lake’s CPIAUCSL rows. It declares the CPI series and reads it all three ways:

func (s *CPIGate) DataRequirements() []algolang.DataRequirement {
    return []algolang.DataRequirement{{
        SeriesID: "cpi", Symbol: s.CPI, Lookback: max(36, s.CorrWindow+14),
        Schema: algolang.DataSchemaSpec{Kind: algolang.Bars, BarKind: algolang.BarsTime, Interval: "1w"},
    }}
}

func (s *CPIGate) OnBar(ctx *algolang.Context, bar algolang.Bar) ([]algolang.Order, error) {
    prim, cpi := ctx.Series(ctx.PrimarySymbol()), ctx.Series("cpi")
    defer s.clock.Observe(cpi, prim) // the release clock: every bar, every series
    if !ctx.IsPrimary(bar) {
        return nil, nil
    }
    // The gate: the newest CPI known at this close. Year on year by Ref,
    // across the missing October 2025.
    latest, ref, ok := kit.PeriodsAgo(cpi, kit.PeriodMonth, 0)
    yearAgo, _, ok12 := kit.PeriodsAgo(cpi, kit.PeriodMonth, 12)
    ...
    // The correlation of ES returns between releases with CPI changes, on
    // the release clock.
    cpiVals, esCloses := s.clock.Series()
    corr := esCloses.ReturnCorrelation(cpiVals, s.CorrWindow, 1).Value()
    ...
}

The first decision that sees each release logs one line per release (several when a catch-up arrives between two hourly bars). On the lake, August 2026 CPI, released at 08:30 New York (12:30 UTC), is first seen by the 13:00 UTC bar, and its year on year uses August 2025, not the July 2025 row twelve releases back:

cpigate: 2026-09-11T13:00:00Z sees CPI 2026-08 released 2026-09-11T12:30:00Z yoy=0.033297 corr=0.0649 n=12

The correlation needs CorrWindow+1 releases observed during the run (warm-up bars do not call OnBar), so it reads NaN for the first year of a run. The CPI series is declared, so the run needs --data-protocol pb (see Declaring extra data).

1d ES with weekly COT and quarterly GDP, lined up. strategies/demo/go/alignpanel is the aligned series example. make run-alignpanel runs it on a daily ES contract against the marketfeed adapter, and engine/gle353_alignpanel_e2e_test.go runs it through the file-csv adapter on the lake’s COT and GDP rows. It declares both release series, then a view of each on the primary, and reads each kind of question from the view that answers it:

func (s *AlignPanel) Lookback() int { return s.Window + 1 } // the primary's depth, and so each view's

func (s *AlignPanel) DataRequirements() []algolang.DataRequirement {
    weekly := algolang.DataSchemaSpec{Kind: algolang.Bars, BarKind: algolang.BarsTime, Interval: "1w"}
    return []algolang.DataRequirement{
        {SeriesID: "cot", Symbol: s.COT, Lookback: s.COTWindow, Schema: weekly}, // the release views
        {SeriesID: "gdp", Symbol: s.GDP, Lookback: 2, Schema: weekly},
    }
}

func (s *AlignPanel) AlignedSeries() []algolang.AlignedSeries {
    return []algolang.AlignedSeries{
        {SeriesID: "cot_d", Source: "cot"}, // one sample per daily bar
        {SeriesID: "gdp_d", Source: "gdp"},
    }
}

func (s *AlignPanel) OnBar(ctx *algolang.Context, bar algolang.Bar) ([]algolang.Order, error) {
    if !ctx.IsPrimary(bar) {
        return nil, nil // a release bar: the views carry it from the next daily close
    }
    prim, cotD, gdpD := ctx.Series(ctx.PrimarySymbol()), ctx.Series("cot_d"), ctx.Series("gdp_d")
    cot, gdp := ctx.Series("cot"), ctx.Series("gdp")
    rows := min(prim.Len(), cotD.Len(), gdpD.Len(), s.Window+1) // right-aligned: row 0 is today in each
    if cotD.Len() > 0 && cotD.Bar(0).Updates > 0 {
        // a report this close carries for the first time: Ref is its as-of date,
        // SourceCloseTime its publication, Updates > 1 a catch-up
    }
    ...
    index := cotIndex(cot, s.COTWindow)                       // statistics: on the release view
    g0, _, ok0 := kit.PeriodsAgo(gdp, kit.PeriodQuarter, 0)   // periods: on the release view, by Ref
    g1, _, ok1 := kit.PeriodsAgo(gdp, kit.PeriodQuarter, 1)
    stale := len(kit.StaleSeries(ctx, s.MaxAge, "cot_d")) > 0 // age of the report, not of the bar
    ...
}

make run-alignpanel runs the December 2025 contract while it was the front, from 15 September to 12 December 2025. At its first decision it logs the panel its warm-up built, and then one line for each daily close that carries a new release:

alignpanel: 2025-09-16T00:00:00Z panel rows=19 primary=19 cot_d=19 gdp_d=19 cot=52 gdp=2 oldest_cot=2025-08-22T20:00:00Z oldest_gdp=2025-07-30T12:30:00Z
alignpanel: 2025-09-20T00:00:00Z carries cot 2025-09-16 released 2025-09-19T20:00:00Z updates=1 value=-472904 index=0.0000 rows=21
alignpanel: 2025-09-27T00:00:00Z carries cot 2025-09-23 released 2025-09-26T20:00:00Z updates=1 value=-442953 index=0.1092 rows=21
alignpanel: 2025-11-20T00:00:00Z carries cot 2025-09-30 released 2025-11-19T21:00:00Z updates=1 value=-437245 index=0.1301 rows=21
alignpanel: 2025-11-22T00:00:00Z carries cot 2025-10-07 released 2025-11-21T21:00:00Z updates=1 value=-456477 index=0.0599 rows=21
alignpanel: 2025-11-26T00:00:00Z carries cot 2025-10-14 released 2025-11-25T21:00:00Z updates=1 value=-409269 index=0.2321 rows=21
...
algo: data degradation: fut:XCME:ES:Z25@1d: warm-up holds 18 of its 21-bar lookback; an indicator reading further back sees a short history

Read it from the top:

  • The panel at the first decision. Each view holds as many rows as the primary: 19, because an epoch daily warm-up spans 21 calendar days (18 bars, hence the warm-up holds 18 of its 21-bar lookback note on the primary, not on the views) plus the first decision. Its oldest COT row carries the report published on 22 August, the newest one before the oldest daily bar. The release views hold their own depth: 52 reports and 2 GDP releases.
  • Each report on its first close. The report published at 20:00 UTC on Friday 19 September is first carried by Friday’s daily bar, which closes at Saturday 00:00 UTC; the bar’s close is after the release, so it is visible.
  • The shutdown. No report appears between 27 September and 20 November: the view repeats the 26 September report on every daily bar for 54 days (Updates == 0), kit.StaleSeries reports it stale once it is older than MaxAge (ten days), and the strategy stays flat.
  • The catch-up. The delayed reports, published a few days apart from 19 November, are each carried by the first daily close after their release, with their own as-of date in Ref. When two land between the same pair of daily bars (a missing day on a thin contract, for instance) the line says updates=2 and the view shows only the newer.

The same panel on the continuous series, with a continuous peer. On a continuous primary the panel’s grid is the continuous series. The Peer input declares a second continuous series beside it, with one bar of its own lookback, and lines it up as peer_d (On a continuous run):

if s.Peer != "" {
    daily := algolang.DataSchemaSpec{Kind: algolang.Bars, BarKind: algolang.BarsTime, Interval: "1d"}
    reqs = append(reqs, algolang.DataRequirement{SeriesID: "peer", Symbol: s.Peer, Lookback: 1, Schema: daily})
}
...
views = append(views, algolang.AlignedSeries{SeriesID: "peer_d", Source: "peer"})
...
peerUp = peerD.Close(0) > indicators.Closes(peerD).SMA(s.Window).Value() // the peer's trend, row for row

The strategy goes long only while the peer trends up too. Each decision that first carries a new peer contract logs the sample’s adjusted and raw close. make run-alignpanel-continuous runs it on fut:XCME:ES with fut:XCME:NQ as the peer, from July to December 2025 (excerpt):

alignpanel: 2025-07-01T21:00:00Z panel rows=21 primary=21 cot_d=21 gdp_d=21 cot=52 gdp=2 oldest_cot=2025-05-30T20:00:00Z oldest_gdp=2025-04-30T12:30:00Z
alignpanel: 2025-07-01T21:00:00Z peer fut:XCME:NQ carries NQU25 close=23185.5 raw=22691.75 rows=21 oldest=NQM25@2025-06-03
alignpanel: 2025-07-07T20:59:59Z carries cot 2025-07-01 released 2025-07-07T20:00:00Z updates=1 value=-302016 index=0.4879 rows=21
alignpanel: 2025-07-11T21:00:00Z carries cot 2025-07-08 released 2025-07-11T20:00:00Z updates=1 value=-340689 index=0.2962 rows=21
...
alignpanel: 2025-09-15T21:00:00Z peer fut:XCME:NQ carries NQZ25 close=24794 raw=24538 rows=21
...
alignpanel: 2025-12-15T21:59:59Z peer fut:XCME:NQ carries NQH26 close=25348.75 raw=25348.75 rows=21
...
  bars:       290 main after replicate (+ 96 warmup), 290 loop, 2 orders
              2 fills, commission 0.00, realized PnL 737.50

Read it from the top:

  • A full panel from the first decision. The continuous series warms up (Lookback and the Series API), so every view holds 21 rows on 1 July, where the dated contract’s held 19.
  • The peer reached back across its roll. NQ’s own lookback is one bar, yet peer_d holds 21 rows. Its oldest row carries NQM25, the contract NQ rolled out of in June, read through NQ’s composite under the run’s snapshot.
  • Reports on their own session. The report published at 20:00 UTC is carried by that day’s session bar, which closes at 20:59:59 or 21:00:00 UTC in summer.
  • The peer’s rolls. At the September roll the sample moves to NQZ25. Its adjusted close (24794) sits 256 points above its raw one (24538), the gap still ahead at the December roll in the run’s frame. From 15 December the sample carries NQH26, the contract held at the anchor, so the two agree.

1h with a declared 1d. Compute each leg on its own clock and combine at the decision. A daily bar that closes at the same instant as an hourly bar is delivered first, so it is already in ctx.Series("d1") on that hourly bar:

d1 := ctx.Series("d1")
up := d1.Len() >= 50 && d1.Close(0) > indicators.Closes(d1).SMA(50).Value()

To build the daily bars from the hourly primary instead, use kit.Resample(prim, kit.TimeframeDay, ny) and read the completed days; kit.PeriodStarts(prim, kit.TimeframeDay, ny) marks each day’s first hour.

Two 1h legs. Statistics use matching hours only; levels carry forward with a staleness check:

a, b, ok := kit.JoinCloses(ctx.Series("es"), ctx.Series("nq"), 100)
if ok {
    beta := ... // regression on a and b: the same 100 hours on both legs
}
if stale := kit.StaleSeries(ctx, 2*time.Hour, "es", "nq"); len(stale) > 0 {
    return nil, nil // a leg has not traded for two hours
}