DpExchange.Core.Timeframe (DpExchangeCore v0.1.4)

Copy Markdown View Source

The canonical timeframe vocabulary: a string label, its width in seconds, and the boundary every real candle of that width sits on.

Why alignment is part of the vocabulary

A candle is not just "OHLC at a time" — it is OHLC for a specific bucket. A 1d bar means midnight-to-midnight UTC; a 4h bar means one of the six fixed 4-hour windows in a day. Every real source honours that: venue candle endpoints return bucket-start timestamps, and any honest aggregation over them rolls up on bucket boundaries.

That makes alignment a cheap, total test of authenticity, and it caught a live data-integrity bug on 2026-08-06. The Gemini adapter, when its historical path returned too little data, synthesised candles at now - i * granularity — arbitrary sub-second timestamps carrying prices from a hardcoded table. The poison is visible at a glance once you know to look:

2026-08-06T00:00:00Z        close 64698.60   <- real
2026-08-04T16:01:33.654710Z close 42912.10   <- fabricated (42_500 base)

Both were tagged timeframe: "1d", both fed backtests and the shadow gate. Nothing downstream could tell them apart, because nothing downstream checked the one property a fabricated bar cannot fake without also being right.

So this module ships aligned?/2, and a consumer storing candles should enforce it on the write path — a misaligned candle rejected rather than stored — and filter on the read path, so rows written before the guard existed never reach a backtest. Core cannot enforce that itself: it owns no storage. It owns the test.

Weekly and monthly are deliberately absent from boundary/2

A 1w bar's boundary depends on which weekday the venue starts its week, and 1M is not a fixed number of seconds at all. Rather than encode a guess that would reject real data, both are absent, and callers treat "no boundary rule" as "cannot check" rather than "invalid".

Summary

Functions

Whether datetime sits exactly on a timeframe bucket boundary.

The start of the bucket datetime falls in, or the input when unknown.

Every timeframe with a known width, shortest first.

Width of a timeframe in seconds, or :error for one we do not model.

Functions

aligned?(datetime, timeframe)

@spec aligned?(DateTime.t(), String.t()) :: boolean()

Whether datetime sits exactly on a timeframe bucket boundary.

Unknown timeframes return true — "we have no rule" must not read as "invalid", or a venue serving a width we do not model would have all of its real data rejected.

boundary(datetime, timeframe)

@spec boundary(DateTime.t(), String.t()) :: DateTime.t()

The start of the bucket datetime falls in, or the input when unknown.

known()

@spec known() :: [String.t()]

Every timeframe with a known width, shortest first.

seconds(timeframe)

@spec seconds(String.t()) :: {:ok, pos_integer()} | :error

Width of a timeframe in seconds, or :error for one we do not model.

:error rather than a default: a wrong width silently mis-buckets every candle it touches, which is exactly the class of failure a fallback hides.