Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/routes/candles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ export function candleRoutes(): Hono {
return c.json({ s: "error", errmsg: "Invalid from/to" }, 400);
}

// Cap the requested span to what could actually produce MAX_BARS buckets
// at this resolution. Without this, the row cap below (MAX_BARS * 10)
// only bounds the RESULT size — the DB still has to scan/sort the full
// matching row set for the requested date range to find the first N in
// ascending order, which is unbounded for a multi-year range on a
// high-volume market regardless of how few rows are eventually returned.
const maxSpanSeconds = MAX_BARS * bucketSeconds;
if (toSec - fromSec > maxSpanSeconds) {
return c.json(
{ s: "error", errmsg: `Requested range exceeds the maximum span for resolution '${resolution}' (${maxSpanSeconds}s)` },
400,
);
}

try {
const { data, error } = await getSupabase()
.from("trades")
Expand Down
32 changes: 30 additions & 2 deletions tests/routes/candles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("GET /candles/:slab", () => {

it("returns no_data when trades table is empty", async () => {
const app = candleRoutes();
const res = await app.request(`/candles/${SLAB}?resolution=1&from=0&to=9999999999`);
const res = await app.request(`/candles/${SLAB}?resolution=1&from=1745150400&to=1745150460`);
expect(res.status).toBe(200);
const body = await res.json() as any;
expect(body.s).toBe("no_data");
Expand All @@ -112,7 +112,7 @@ describe("GET /candles/:slab", () => {
error: null,
});
const app = candleRoutes();
const res = await app.request(`/candles/${SLAB}?resolution=1&from=0&to=9999999999`);
const res = await app.request(`/candles/${SLAB}?resolution=1&from=1745150400&to=1745150460`);
const body = await res.json() as any;
expect(body.s).toBe("ok");
expect(body.t).toHaveLength(1);
Expand All @@ -131,4 +131,32 @@ describe("GET /candles/:slab", () => {
const res = await app.request(`/candles/${SLAB}?resolution=1&from=1000&to=500`);
expect(res.status).toBe(400);
});

describe("max date-span cap (BUG-008)", () => {
it("rejects a range wider than MAX_BARS buckets at the requested resolution, without querying the DB", async () => {
const app = candleRoutes();
// resolution=1 (60s buckets) — MAX_BARS(5000) * 60s = 300,000s max span.
// Request a multi-year range, far beyond that.
const res = await app.request(`/candles/${SLAB}?resolution=1&from=0&to=9999999999`);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.errmsg).toMatch(/exceeds the maximum span/i);
// The query must never have been issued for an out-of-bounds range.
expect(mockSupabase.from).not.toHaveBeenCalled();
});

it("allows a range exactly at the max span for the requested resolution", async () => {
const app = candleRoutes();
const maxSpan = 5000 * 60; // MAX_BARS * RES_TO_SECONDS["1"]
const res = await app.request(`/candles/${SLAB}?resolution=1&from=1000000&to=${1000000 + maxSpan}`);
expect(res.status).toBe(200);
});

it("scales the allowed span with resolution — a multi-year range is fine at daily resolution", async () => {
const app = candleRoutes();
// resolution=1D (86400s buckets) — MAX_BARS(5000) * 1 day ≈ 13.7 years.
const res = await app.request(`/candles/${SLAB}?resolution=1D&from=0&to=400000000`); // ~12.7 years
expect(res.status).toBe(200);
});
});
});