---
title: "Market Data TTL"
description: "The market-data service attaches a time-to-live to every quote and resolves the effective lifetime of a read through a multi-axis cascade. This page."
---

<!-- markdownlint-disable MD010 MD033 -->
# Market Data TTL

The market-data service attaches a time-to-live to every quote and resolves the
effective lifetime of a read through a multi-axis cascade. This page covers quote
freshness and the full TTL cascade. For registration, quote buckets, and the
read/write API see [Market Data](Market-Data.md). For the threading model see
[Threading Contract](Threading-Contract.md).

## Quote Freshness

The operator sets the effective time-to-live, while the publisher supplies each
quote's source age: the elapsed time from source observation to publication. A
quote expires when its source age plus the time since publication reaches the
effective lifetime. A source that observed a quote 20 ms ago publishes it with a
20 ms source age; with a 50 ms lifetime, the quote is readable for only about
30 ms after publication. Replaying an old quote therefore never makes it fresh.

The service reads a quote and its effective TTL atomically, so one read cannot
combine a quote from before publication with a lifetime set afterwards. TTL
setters retain their existing meaning: they are the operator's instrument,
account, group, and service-default controls, not a way to describe a quote's
age. Once a quote reaches its effective time-to-live a read reports it as
absent, even though the instrument stays registered. Publishing a new quote with
its own source age restores visibility. An infinite time-to-live keeps the last
quote visible until it is replaced or cleared.

The effective TTL for a read is resolved per `(instrument, account, group)` by a
cascade of eight tiers - see [TTL Cascade](#ttl-cascade) for the full order.

This matters for policy pricing: a policy that asks for a price gets nothing when
the quote has gone stale, which is why the [spot funds](Spot-Funds.md) policy
rejects a market order whose pricing quote is unavailable rather than trading on
a stale price.

<details>
<summary>Go</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_market_data_test.go -->
```go
// noGroupInfo is a minimal AccountInfo whose reading account has no group.
type noGroupInfo struct{}

func (noGroupInfo) AccountGroup() optional.Option[param.AccountGroupID] {
    return optional.None[param.AccountGroupID]()
}

// A 50 ms service-wide lifetime: quotes older than that read as absent.
service, err := openpit.NewEngineBuilder().
    FullSync().
    MarketData(marketdata.WithinTTL(50 * time.Millisecond)).
    Build()
if err != nil {
    panic(err)
}
defer service.Close()

aapl, _ := param.NewAsset("AAPL")
usd, _ := param.NewAsset("USD")
aaplID, err := service.Register(param.NewInstrument(aapl, usd))
if err != nil {
    panic(err)
}

mark, _ := param.NewPriceFromString("200")
if err := service.Push(
    aaplID,
    marketdata.NewQuote().WithMark(mark),
    0,
); err != nil {
    panic(err)
}

accountID := param.NewAccountIDFromUint64(1)
if _, ok := service.GetOptional(
    aaplID, accountID, noGroupInfo{},
    marketdata.QuoteResolutionAccountThenGroupThenDefault,
).Get(); !ok {
    panic("quote must be present right after push")
}

// After the lifetime elapses the quote reads as absent.
time.Sleep(80 * time.Millisecond)
if _, ok := service.GetOptional(
    aaplID, accountID, noGroupInfo{},
    marketdata.QuoteResolutionAccountThenGroupThenDefault,
).Get(); ok {
    panic("quote must be absent after TTL expires")
}

// A fresh push restores visibility.
fresh, _ := param.NewPriceFromString("205")
if err := service.Push(
    aaplID,
    marketdata.NewQuote().WithMark(fresh),
    0,
); err != nil {
    panic(err)
}
quote, ok := service.GetOptional(
    aaplID, accountID, noGroupInfo{},
    marketdata.QuoteResolutionAccountThenGroupThenDefault,
).Get()
if !ok {
    panic("quote must be present after fresh push")
}
if got, _ := quote.Mark().Get(); !got.Equal(fresh) {
    panic("unexpected mark after fresh push")
}
```

</details>

<details>
<summary>Python</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_market_data.py -->
```python
import time
import types
from datetime import timedelta

import openpit
import openpit.marketdata

# A 50 ms service-wide lifetime: quotes older than that read as absent.
service = (
    openpit.Engine.builder()
    .no_sync()
    .market_data(openpit.marketdata.QuoteTtl.within(timedelta(milliseconds=50)))
    .build()
)
aapl_id = service.register(openpit.Instrument("AAPL", "USD"))

account_id = openpit.param.AccountId.from_int(1)
account_info = types.SimpleNamespace(account_group=None)

def read():
    return service.get_optional(
        aapl_id,
        account_id,
        account_info,
        openpit.marketdata.QuoteResolution.ACCOUNT_THEN_GROUP_THEN_DEFAULT,
    )

service.push(aapl_id, openpit.marketdata.Quote(mark="200"), timedelta())
assert read() is not None

# After the lifetime elapses the quote reads as absent.
time.sleep(0.08)
assert read() is None

# A fresh push restores visibility.
service.push(aapl_id, openpit.marketdata.Quote(mark="205"), timedelta())
assert read() is not None
```

</details>

<details>
<summary>JavaScript</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_market_data.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import {
  QuoteExpired,
  QuoteResolution,
  QuoteTtl,
} from "@openpit/engine/marketdata";

// A 50 ms service-wide lifetime: getOrErr distinguishes expired quotes.
const service = Engine.builder()
  .marketData(QuoteTtl.within(50))
  .build();
const aaplId = service.register({
  underlyingAsset: "AAPL",
  settlementAsset: "USD",
}).value;

const accountId = 1;
// A reading account with no group: any object exposing an `accountGroup` getter.
const accountInfo = { accountGroup: null };
const read = () =>
  service.getOrErr(
    aaplId,
    accountId,
    accountInfo,
    QuoteResolution.ACCOUNT_THEN_GROUP_THEN_DEFAULT(),
  );

service.push(aaplId, { mark: "200" }, 0);
const fresh = read();

// After the lifetime elapses, QuoteExpired preserves the stale snapshot.
await new Promise<void>((resolve) => setTimeout(resolve, 80));

let expiredMark: string | undefined;
try {
  read();
  throw new Error("the stale quote must expire");
} catch (error) {
  if (!(error instanceof QuoteExpired)) {
    throw error;
  }
  expiredMark = error.quote.mark?.toString();
}

// A fresh push restores visibility.
service.push(aaplId, { mark: "205" }, 0);
const restored = read();

if (
  fresh.mark?.toString() !== "200" ||
  expiredMark !== "200" ||
  restored.mark?.toString() !== "205"
) {
  throw new Error("unexpected quote freshness result");
}
```

</details>

<details>
<summary>C++</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/market_data_ttl_test.cpp -->
```cpp
#include "openpit/param/account_id.hpp"
#include "openpit/marketdata.hpp"
#include "openpit/model/model.hpp"
#include "openpit/param/param.hpp"

#include <cassert>
#include <chrono>
#include <optional>
#include <thread>

namespace md = openpit::marketdata;
using openpit::param::AccountGroupId;
using openpit::param::AccountId;
using openpit::param::Price;

// AccountInfo for a reading account that belongs to no group.
struct NoGroupInfo {
  [[nodiscard]] std::optional<AccountGroupId> AccountGroup() const {
    return std::nullopt;
  }
};

// A 50 ms service-wide lifetime: quotes older than that read as absent.
md::Service service =
    md::Builder(md::QuoteTtl::Within(std::chrono::milliseconds(50)),
                md::SyncPolicy::None)
        .Build();

const md::RegisterResult registration =
    service.Register(openpit::model::Instrument(
        ::openpit::param::Asset("AAPL"), ::openpit::param::Asset("USD")));
assert(registration.status == md::RegisterStatus::Ok);
assert(registration.instrumentId.has_value());
const md::InstrumentId aaplId = registration.instrumentId.value();

const AccountId accountId = AccountId::FromUint64(1);

auto read = [&]() -> std::optional<md::Quote> {
  return service.Find(aaplId, accountId, NoGroupInfo{},
                      md::QuoteResolution::AccountThenGroupThenDefault);
};

assert(service.Push(aaplId, md::Quote().WithMark(Price::FromString("200")),
                    std::chrono::nanoseconds::zero()) ==
       md::RegisterStatus::Ok);
assert(read().has_value());

// After the lifetime elapses the quote reads as absent.
std::this_thread::sleep_for(std::chrono::milliseconds(80));
assert(!read().has_value());

// A fresh push restores visibility.
assert(service.Push(aaplId, md::Quote().WithMark(Price::FromString("205")),
                    std::chrono::nanoseconds::zero()) ==
       md::RegisterStatus::Ok);
const std::optional<md::Quote> quote = read();
assert(quote.has_value());
assert(quote->Mark()->ToString() == "205");
```

</details>

<details>
<summary>Rust</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_market_data.rs -->
```rust
use std::time::Duration;

use openpit::param::{AccountId, AccountGroupId, Asset, Price};
use openpit::{Engine, Instrument, Quote, QuoteResolution, QuoteTtl};

// A 50 ms service-wide lifetime: quotes older than that read as absent.
let service = Engine::builder::<(), (), ()>()
    .no_sync()
    .market_data(QuoteTtl::Within(Duration::from_millis(50)))
    .build();
let aapl_id = service.register(Instrument::new(Asset::new("AAPL")?, Asset::new("USD")?))?;

let account = AccountId::from_u64(1);
let read = |id| {
    service
        .get(
            id,
            account,
            &None::<AccountGroupId>,
            QuoteResolution::AccountThenGroupThenDefault,
        )
        .ok()
};

service.push(
    aapl_id,
    Quote::new().with_mark(Price::from_str("200")?),
    Duration::ZERO,
)?;
assert!(read(aapl_id).is_some());

// After the lifetime elapses the quote is hidden until the next push.
std::thread::sleep(Duration::from_millis(80));
assert!(read(aapl_id).is_none());

// A fresh push restores visibility.
service.push(
    aapl_id,
    Quote::new().with_mark(Price::from_str("205")?),
    Duration::ZERO,
)?;
let quote = read(aapl_id).expect("quote must be present");
assert_eq!(quote.mark, Some(Price::from_str("205")?));
```

</details>

## TTL Cascade

The effective lifetime for a read of `(instrument, account, group)` is
resolved by walking the following tiers in order and stopping at the
**first set** value:

<!-- markdownlint-disable MD013 MD060 -->
| Tier | Cell |
|------|------|
| 1 | instrument x account |
| 2 | instrument x group (skipped when the account has no group) |
| 3 | instrument x default-group |
| 4 | service-level account |
| 5 | service-level group (skipped when the account has no group) |
| 6 | service-level default-group |
| 7 | instrument-level (the registration TTL) |
| 8 | service-wide default from the builder |
<!-- markdownlint-enable MD013 MD060 -->

A set `Infinite` TTL stops the cascade immediately and means "no expiry". The
TTL is resolved for the **requested** `(account, group)`, independent of which
bucket (account, group, or default) the quote was actually found in.

Tiers 2 and 5 use the same lazy group resolution as quote selection, and it
shares its three-valued answer: a group, no group, or an accessor that could not
answer. The last case fails the read instead of skipping the group tiers - see
[Resolving the Account Group](Market-Data.md#resolving-the-account-group).

TTL cells are set and cleared via the `set * ttl` / `clear * ttl` family:

<!-- markdownlint-disable MD013 MD060 -->
| Setter | Tier |
|--------|------|
| `set instrument account ttl(id, account, ttl)` | 1 |
| `set instrument account group ttl(id, group, ttl)` | 2-3 |
| `set account ttl(account, ttl)` | 4 |
| `set account group ttl(group, ttl)` | 5-6 |
| `set instrument ttl(id, ttl)` | 7 |
| builder default TTL | 8 |
<!-- markdownlint-enable MD013 MD060 -->

Pass the `default account group` to `set instrument account group ttl` or
`set account group ttl` to address tiers 3 and 6 respectively. Each setter has
a matching `clear * ttl` counterpart that reverts the cell to "inherit".

## Related Pages

- [Market Data](Market-Data.md) - registration, quote buckets, and the read/write
  API.
- [Market Data Pricing](Market-Data-Pricing.md) - market-order pricing from the
  quote cache.
- [Account Groups](Account-Groups.md) - group membership drives the group tiers of
  the cascade.
- [Spot Funds](Spot-Funds.md) - rejects market orders priced from a stale quote.
