Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/half-open-window-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"lens": minor
---

**Breaking:** range endpoints now use half-open intervals — `[from, to)`, not
`[from, to]`. `from` is inclusive, `to` is exclusive.

`GET /candles/:assetA/:assetB` and `GET /prices/history` previously returned
samples with `timestamp` in `from..to` inclusive at both ends. A caller paging
back-to-back windows (`[T0, T1]`, then `[T1, T2]`) got every sample landing
exactly on `T1` **twice** — once in each response — so the two windows
together double-counted the boundary and inflated any total, average or VWAP
computed over them.

`to` is now exclusive. Adjacent windows `[T0, T1)` and `[T1, T2)` share no
boundary, and a sample landing exactly on `T1` appears in exactly one of the
two responses.

Callers that relied on the inclusive end must widen their `to` by one tick
(the smallest interval they query) to keep the same rows — e.g. `[T0, T1]` is
now `[T0, T1 + 1s)` at `interval=1m`.

Every `price_points` query now also carries the `network` predicate, so the
`(network, pair_key, timestamp)` index is usable and a pair's price is never a
blend of two chains. `GET /candles/:assetA/:assetB` and `GET /basket` were
reading both networks; `GET /benchmark/:asset` was computing its deviation
figures from both. They now default to the active network and honour
`?network=` / `x-network`.
4 changes: 4 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@
"name": "from",
"in": "query",
"required": false,
"description": "Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.",
"schema": {
"type": "string",
"format": "date-time"
Expand All @@ -697,6 +698,7 @@
"name": "to",
"in": "query",
"required": false,
"description": "End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.",
"schema": {
"type": "string",
"format": "date-time"
Expand Down Expand Up @@ -810,6 +812,7 @@
"name": "from",
"in": "query",
"required": false,
"description": "Start of the time window (inclusive). Together with `to` this defines a half-open interval [from, to): samples whose timestamp is exactly `from` are included, samples whose timestamp is exactly `to` are excluded. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.",
"schema": {
"type": "string",
"format": "date-time"
Expand All @@ -819,6 +822,7 @@
"name": "to",
"in": "query",
"required": false,
"description": "End of the time window (exclusive). Together with `from` this defines a half-open interval [from, to): samples whose timestamp is exactly `to` are excluded, samples whose timestamp is exactly `from` are included. This means two adjacent windows [T0, T1) and [T1, T2) share no boundary — a sample landing exactly on T1 appears in exactly one of the two windows.",
"schema": {
"type": "string",
"format": "date-time"
Expand Down
28 changes: 28 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -467,12 +467,26 @@ paths:
- name: from
in: query
required: false
description: >-
Start of the time window (inclusive). Together with `to` this defines
a half-open interval [from, to): samples whose timestamp is exactly
`from` are included, samples whose timestamp is exactly `to` are
excluded. This means two adjacent windows [T0, T1) and [T1, T2)
share no boundary — a sample landing exactly on T1 appears in
exactly one of the two windows.
schema:
type: string
format: date-time
- name: to
in: query
required: false
description: >-
End of the time window (exclusive). Together with `from` this
defines a half-open interval [from, to): samples whose timestamp is
exactly `to` are excluded, samples whose timestamp is exactly
`from` are included. This means two adjacent windows [T0, T1) and
[T1, T2) share no boundary — a sample landing exactly on T1 appears
in exactly one of the two windows.
schema:
type: string
format: date-time
Expand Down Expand Up @@ -547,12 +561,26 @@ paths:
- name: from
in: query
required: false
description: >-
Start of the time window (inclusive). Together with `to` this defines
a half-open interval [from, to): samples whose timestamp is exactly
`from` are included, samples whose timestamp is exactly `to` are
excluded. This means two adjacent windows [T0, T1) and [T1, T2)
share no boundary — a sample landing exactly on T1 appears in
exactly one of the two windows.
schema:
type: string
format: date-time
- name: to
in: query
required: false
description: >-
End of the time window (exclusive). Together with `from` this
defines a half-open interval [from, to): samples whose timestamp is
exactly `to` are excluded, samples whose timestamp is exactly
`from` are included. This means two adjacent windows [T0, T1) and
[T1, T2) share no boundary — a sample landing exactly on T1 appears
in exactly one of the two windows.
schema:
type: string
format: date-time
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/candles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,16 @@ describe('GET /candles/:assetA/:assetB', () => {
expect(new Date(params[2]).toISOString()).toBe(from)
expect(new Date(params[3]).toISOString()).toBe(to)
})

it('uses half-open interval [from, to) in SQL query', async () => {
const app = await buildApp()
mockQuery.mockResolvedValue({ rows: [] })

await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h&from=2025-01-01T00:00:00Z&to=2025-01-01T01:00:00Z' })

// The SQL query should use < (exclusive) for the upper bound, not <=
const sql = mockQuery.mock.calls[0][0]
expect(sql).toMatch(/AND timestamp < \$4/)
expect(sql).not.toMatch(/AND timestamp <= \$4/)
})
})
12 changes: 12 additions & 0 deletions src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ describe('GET /prices/history', () => {
expect(new Date(params[3]).toISOString()).toBe(to)
})

it('uses half-open interval [from, to) in SQL query', async () => {
const app = await buildApp()
mockQuery.mockResolvedValue({ rows: [] })

await app.inject({ method: 'GET', url: '/prices/history?pair=XLM/USDC&from=2025-01-01T00:00:00Z&to=2025-01-01T01:00:00Z' })

// The SQL query should use < (exclusive) for the upper bound, not <=
const sql = mockQuery.mock.calls[0][0]
expect(sql).toMatch(/AND ts < \$4/)
expect(sql).not.toMatch(/AND ts <= \$4/)
})

it('MAX_HISTORY_POINTS guard math is interval-aware', () => {
// Sanity: the cap is points, not seconds — coarser intervals allow longer spans.
expect(MAX_HISTORY_POINTS).toBeGreaterThan(0)
Expand Down
Loading