Raw and adjusted prices

Raw and adjusted prices

For continuous futures and split/dividend-adjusted equities the engine delivers an adjusted price series — and bar.Close, series.Close(i), indicators.Closes(series), and fill.Price all are that adjusted view, so indicators compute in one continuous price regime across a roll or split and your strategy needs no special-casing. For unadjusted data the adjusted view simply equals the raw value, so nothing changes. For an equity the adjusted view is the one marketfeed serves under eq:V:T:adj=split or adj=splitdiv (GLE-328): the strategy receives the served bar, the simulator fills on its raw prices, and fill.RawPrice() is the price the venue filled at.

If you also need the venue-real, on-the-day price — for real-dollar accounting, risk reporting, or placing an order anchored to an actual price rather than the adjusted level — a small set of opt-in accessors exposes it:

bar.AdjustedPrice(algolang.FieldClose) // == bar.Close (the default view)
bar.RawPrice(algolang.FieldClose)      // the broker-real close (== adjusted when unadjusted)
bar.IsAdjusted()                       // does this bar carry a distinct raw view?
fill.AdjustedPrice()                   // == fill.Price
fill.RawPrice()                        // the broker-real fill price
ctx.CurrentAdjustment("ES")            // additive offset in effect (Panama/futures; 0 = none)
ctx.CurrentAdjustmentRatio("AAPL")     // multiplicative factor in effect (equity/split; 1.0 = none)
ctx.Position("AAPL").AvgPrice          // adjusted-frame avg cost (the default view)
ctx.Position("AAPL").AvgPriceRaw       // venue-real (on-the-day) avg cost; AvgPriceAdjusted is the adjusted one

A strategy that never references these is fully isolated from the raw-versus-adjusted distinction.

The strategy’s view of continuous futures

The engine hides dated-contract changes behind one logical root. A strategy receives adjusted bars under that root, submits orders for that root, and keeps one logical position while the engine translates venue-real contracts and roll orders underneath it. Most strategies need no roll-specific code. The adjusted bar is the feed’s: marketfeed builds the adjusted series and the per-contract constants, and the engine passes them on as served, using the constants only to translate orders (GLE-323). Nothing changes for the strategy.

On a daily continuous run (--interval 1d or 1w) the bars are the feed’s trade-date session bars (GLE-324), so Bar.Session is set as it is on a dated contract’s session run: the trade date, the session kind actually served (eth unless the run named rth or session), the settlement with HasSettle and CloseIsSettlement, and EarlyClose. The settle is in the served, adjusted frame, beside the adjusted close. Intraday continuous bars are UTC epoch buckets and carry no session block, and neither does any bar under --continuous-clock epoch.

Each continuous bar carries the dated contract it was served from in Bar.ActiveContract (for example ESM26), and ctx.ActiveContract(symbol) returns the one a continuous series currently holds (GLE-420). A strategy may address an order to that contract instead of the logical root, priced in raw terms (Bar.RawClose); ctx.Flat(root) and ctx.Exposure(root) count a resting order on the root’s held contract against the root, so a contract-addressed order is not mistaken for a flat book. Each continuous series keeps its own held contract, keyed by its logical symbol:

func (s *Strategy) OnBar(ctx *algolang.Context, bar algolang.Bar) ([]algolang.Order, error) {
    contract, ok := ctx.ActiveContract(bar.Symbol)
    if !ok || !ctx.Flat(bar.Symbol) {
        return nil, nil // not continuous yet, or already exposed (either form)
    }
    o := algolang.LimitBuy(contract, 1, bar.RawClose-0.25) // a raw-priced bid on the held contract
    o.ClientID = "bid_" + contract
    return []algolang.Order{o}, nil
}

A strategy that wants an informational callback implements RollObserver:

func (s *Strategy) OnRoll(symbol, from, to string, at time.Time,
    rawGap, priceRatio float64) {
    // Observe or log the engine's decision; the callback cannot change it.
}

The marketfeed chapter explains how dated bars become this logical series, including bare roots and named :cont composites, the served roll schedule and price adjustment.

Several continuous series in one run

A run can hold several continuous series, each of a different root, beside any plain series (GLE-424): --symbols fut:XCME:ES,fut:XCME:NQ, or a series list in a config. Each root is stitched and rolled by its own served schedule, and the strategy sees every series under its own logical symbol. Four things follow:

  • Bars come one series at a time. The two legs of one trade date are two bars. Their order is not fixed, and they can close a second or two apart: a session bar closes on its last trade. Pair them by Bar.Session.TradeDate, not by the close time.
  • An order reaches its series from any bar. An order for a logical symbol, or for its active contract, goes to that series whatever bar it was placed on. A spread places both legs on the second leg’s bar.
  • Positions are per logical symbol. ctx.Position("fut:XCME:NQ") is the NQ series’ lots, rolled with it. A dated contract of the same root configured beside it (fut:XCME:ES:M26 beside fut:XCME:ES) is a second position that never rolls.
  • Each series keeps its own artefacts. Each series’ manifest and audit ledger go to continuous/<series>/ under the run’s results directory (GLE-425).

strategies/demo/go/esnqspread is the worked example: an E-mini S&P 500 / E-mini Nasdaq-100 relative-value strategy. On each trade date with both legs it takes the log ratio of the adjusted closes and standardises it over 20 trade dates (spread.go). It sells the spread (ES short, NQ long, NQ sized to ES’s notional) at a z-score of +2, buys it at -2, and flattens within 0.5 of zero. The pairing is the only multi-series code it needs:

date := bar.Session.TradeDate
es, nq, ok := s.pair.Add(leg, date, bar.Close) // the trade date's second leg completes it
if !ok {
    return nil, nil
}
z, ok := s.model.Observe(es, nq)
...
case EnterShort:
    add(algolang.MarketSell(s.ES, s.params.ESLots), "short-es")
    add(algolang.MarketBuy(s.NQ, s.params.NQLots(es, nq)), "short-nq")

make run-esnqspread runs it over the archive from 2022 through 2024 (ESNQ_START, ESNQ_END). The decisions log to stderr, and the report shows the one instance over both series (excerpt):

[esnqspread] 2022-02-21 z=+2.08 sell the spread: sell 1 ES, buy 1 NQ
[esnqspread] 2022-02-24 z=-0.16 flatten the spread
[esnqspread] 2022-03-07 z=+2.31 sell the spread: sell 1 ES, buy 1 NQ
[esnqspread] 2022-03-09 z=+0.38 flatten the spread
...
Algolang run run-fut-xcme-es+fut-xcme-nq-1d
  instance:   1 (fut:XCME:ES@1d + fut:XCME:NQ@1d), 128 orders
  bars:       1,550 main after replicate (+ 0 warmup), 1,550 loop, 128 orders
  simulator:  resolution=bar bracket_ambiguity=conservative limit_fill=conservative slippage_ticks=0
              148 fills, commission 0.00, realized PnL 57437.50
              equity 157437.50 (capital 100000.00, return +57.4375%)
  performance: net profit +57437.50 (+57.44%) | closed equity 157437.50
               max drawdown 44530.00 (36.93%) peak 2022-08-09 trough 2023-05-26 | sharpe 0.82 | cagr +16.38%
               trades 64 closed | win rate 57.81% (37W/27L/0E gross) | profit factor 1.31

The 148 fills are the 128 strategy orders plus 20 roll legs: the rolls that fell while a spread was open, each executed by the engine at its root’s served cut. This is an example of the mechanics, not a tested edge: the spread is not cointegration-tested, and the run charges no commission or slippage. TestGoldenEsnqspreadDecisionsMatchTheServedComposites, under make test-golden, checks the run. It dumps the served bars of both series with printbars, replays the model over them, and requires every decided order, and no other, among the run’s fills.