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
5 changes: 5 additions & 0 deletions .changeset/price-history-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"lens": minor
---

Add `/prices/history` endpoint backed by 1-minute price snapshots. A new `price_snapshots` table is appended to every minute by a snapshot ingester, queryable over a `[from, to]` window with optional `5m`/`1h` aggregation. A retention job prunes snapshots older than 30 days.
129 changes: 129 additions & 0 deletions .github/workflows/load.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Load test (prices)

# Price endpoints are polled hard by trading bots, so we guard their latency
# under load on a schedule (and on demand). Acceptance criterion: p95 < 80ms at
# 5k RPS, enforced by the k6 thresholds in tests/load/prices.k6.js (the run
# exits non-zero if a threshold is breached).
on:
schedule:
# 04:00 UTC daily — off-peak, so a sustained 5k-RPS flood doesn't collide
# with interactive CI.
- cron: '0 4 * * *'
workflow_dispatch:
inputs:
rate:
description: 'Target requests per second'
required: false
default: '5000'
duration:
description: 'Steady-state duration (k6 syntax, e.g. 1m, 30s)'
required: false
default: '1m'

jobs:
k6:
name: k6 — p95 < 80ms @ 5k RPS
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: lens
POSTGRES_PASSWORD: lens
POSTGRES_DB: lens
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U lens -d lens"
--health-interval 5s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5

env:
DATABASE_URL: postgresql://lens:lens@localhost:5432/lens
REDIS_URL: redis://localhost:6379
WATCHED_PAIRS: XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
# Isolate endpoint performance: disable the per-key API auth so the load
# generator can flood without a valid key, and the auth lookup (a DB round
# trip per request) doesn't dominate the measurement.
REQUIRE_API_KEY: 'false'
PORT: '3002'
HOST: '0.0.0.0'
# The limiter would 429 a 5k-RPS flood from a single IP; raise the IP
# fallback well above the target rate so the test measures the endpoint,
# not the limiter.
RATE_LIMIT_IP_MAX: '100000000'

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm

- name: Install dependencies
run: npm ci || npm install

- name: Generate Prisma client
run: node node_modules/prisma/build/index.js generate

- name: Build
run: npm run build

- name: Apply database schema
run: node node_modules/prisma/build/index.js db push --accept-data-loss

- name: Start server
run: |
npm start > server.log 2>&1 &
echo "SERVER_PID=$!" >> "$GITHUB_ENV"

- name: Wait for server to be ready
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:3002/status > /dev/null; then
echo "Server is up."
exit 0
fi
sleep 2
done
echo "Server did not become ready in time. Logs:"
cat server.log
exit 1

- name: Warm the price cache
# Prime Redis so the measured run represents the production hot path
# (bots polling an already-cached price), not a cold first miss.
run: |
curl -sf "http://localhost:3002/price/XLM/USDC" > /dev/null || {
echo "Warmup request failed. Server logs:"
cat server.log
exit 1
}

- name: Install k6
uses: grafana/setup-k6-action@v1

- name: Run k6 load test
uses: grafana/run-k6-action@v1
with:
path: tests/load/prices.k6.js
env:
BASE_URL: http://localhost:3002
RATE: ${{ github.event.inputs.rate || '5000' }}
DURATION: ${{ github.event.inputs.duration || '1m' }}

- name: Dump server logs on failure
if: failure()
run: cat server.log || true
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity
| GET | `/price/:assetA/:assetB` | Current VWAP, 24h volume, best route |
| GET | `/price/:assetA/:assetB/route?amount=1000` | Best execution route for a given amount |
| GET | `/price/:assetA/:assetB/history?window=1h` | OHLCV history (`1m`, `5m`, `1h`, `24h`) |
| GET | `/prices/history?pair=XLM/USDC&from=…&to=…&interval=1m` | Historical 1-minute price snapshots, optionally aggregated (`1m`, `5m`, `1h`); ~30-day retention |
| GET | `/pools` | Active AMM pools being watched |
| GET | `/pairs` | Watched trading pairs |
| GET | `/status` | Indexer health |
Expand Down
75 changes: 75 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,81 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/prices/history:
get:
operationId: getPriceHistory
summary: Get historical 1-minute price snapshots
description: >-
Returns minute-resolution price snapshots for a pair over [from, to],
optionally aggregated into 5m or 1h buckets. Snapshots are retained for
~30 days. Gated by x402 (matches the /price prefix).
security:
- x402: []
parameters:
- name: pair
in: query
required: true
schema:
type: string
example: XLM/USDC
- name: interval
in: query
required: false
schema:
type: string
enum: [1m, 5m, 1h]
default: 1m
- name: from
in: query
required: false
schema:
type: string
format: date-time
- name: to
in: query
required: false
schema:
type: string
format: date-time
responses:
'200':
description: Historical price snapshots
content:
application/json:
schema:
type: object
properties:
pair:
type: string
interval:
type: string
from:
type: string
format: date-time
to:
type: string
format: date-time
count:
type: integer
points:
type: array
items:
type: object
properties:
ts:
type: string
format: date-time
price:
type: number
volume:
type: number
'400':
description: Invalid parameters
'402':
$ref: '#/components/responses/PaymentRequired'
'429':
$ref: '#/components/responses/TooManyRequests'

/pairs:
get:
operationId: listPairs
Expand Down
11 changes: 11 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ model PriceAggregate {
@@map("price_aggregates")
}

model PriceSnapshot {
pair String
ts DateTime
price Decimal @db.Decimal(36, 18)
volume Decimal @default(0) @db.Decimal(36, 7)

@@id([pair, ts])
@@index([pair, ts])
@@map("price_snapshots")
}

model IndexerState {
id String @id
lastCursor String? @map("last_cursor")
Expand Down
14 changes: 14 additions & 0 deletions sql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ CREATE TABLE IF NOT EXISTS price_aggregates (
PRIMARY KEY (pair_key, "window", bucket)
);

-- 1-minute price snapshot ring buffer.
-- The ingester appends one row per watched pair per minute; a retention job
-- prunes rows older than 30 days. Powers the /prices/history endpoint (charts,
-- backtests, audit trails) without paying the cost of scanning raw price_points.
CREATE TABLE IF NOT EXISTS price_snapshots (
pair TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
price NUMERIC(36, 18) NOT NULL,
volume NUMERIC(36, 7) NOT NULL DEFAULT 0,
PRIMARY KEY (pair, ts)
);

CREATE INDEX IF NOT EXISTS idx_price_snapshots_pair_ts ON price_snapshots (pair, ts);

-- Indexer cursor state
CREATE TABLE IF NOT EXISTS indexer_state (
id TEXT PRIMARY KEY,
Expand Down
Loading