Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/theonetrade/backtest-kit-redis-postgres-pgpool-docker/llms.txt

Use this file to discover all available pages before exploring further.

The Candle adapter persists OHLCV (open/high/low/close/volume) bars to the candle-items table in PostgreSQL and maintains a Redis cache keyed by (symbol, interval, exchangeName, timestamp) for O(1) lookups. Unlike every other adapter in the stack, candle rows are immutable after their first insert — once a bar is written its price data is never overwritten, preserving the integrity of historical market data across restarts and replays.

Context Key and Registration

The adapter is instantiated per (symbol, interval, exchangeName) triple and registered in src/config/setup.ts:
PersistCandleAdapter.usePersistCandleAdapter(class implements IPersistCandleInstance {
  constructor(
    readonly symbol: string,
    readonly interval: CandleInterval,
    readonly exchangeName: string,
  ) {}

  async waitForInit(initial: boolean) {
    if (!initial) return;
    await waitForInfra();   // singleshot Postgres + Redis readiness gate
  }

  async writeCandlesData(candles: CandleData[]): Promise<void> {
    for (const candle of candles) {
      await ioc.candleDbService.create({
        symbol: this.symbol,
        interval: this.interval,
        close: candle.close,
        high: candle.high,
        low: candle.low,
        open: candle.open,
        timestamp: candle.timestamp,
        volume: candle.volume,
      });
    }
  }

  async readCandlesData(limit: number, sinceTimestamp: number) {
    const stepMs = INTERVAL_MINUTES[this.interval] * MS_PER_MINUTE;
    const result: CandleData[] = [];
    for (let i = 0; i < limit; i++) {
      const ts = sinceTimestamp + i * stepMs;
      const row = await ioc.candleDbService.findBySymbolIntervalTimestamp(
        this.symbol, this.interval, ts
      );
      if (!row) return null;
      result.push({
        timestamp: row.timestamp,
        open: row.open, high: row.high,
        low: row.low, close: row.close,
        volume: row.volume,
      });
    }
    return result;
  }
});

CandleInterval Values

The interval constructor field must be one of the following string literals from backtest-kit:
ValueDuration
"1m"1 minute
"3m"3 minutes
"5m"5 minutes
"15m"15 minutes
"30m"30 minutes
The readCandlesData method uses a lookup table (INTERVAL_MINUTES) to compute the millisecond step size between consecutive bars, iterating from sinceTimestamp for exactly limit steps. If any expected bar is missing from the database the method returns null rather than a partial array.

Table Schema

The TypeORM EntitySchema for candle-items is defined in src/schema/Candle.schema.ts:
const CandleModel = new EntitySchema<ICandleRow>({
  name: "candle-items",
  columns: {
    id:           { type: "uuid", primary: true, generated: "uuid" },
    symbol:       { type: String },
    interval:     { type: String },
    timestamp:    { type: "bigint", transformer: epochTransformer },
    exchangeName: { type: String },
    open:         { type: "double precision" },
    high:         { type: "double precision" },
    low:          { type: "double precision" },
    close:        { type: "double precision" },
    volume:       { type: "double precision" },
    createDate:   { type: "timestamptz", createDate: true },
    updatedDate:  { type: "timestamptz", updateDate: true },
  },
  indices: [
    {
      name: "candle_items_uq",
      columns: ["symbol", "interval", "timestamp"],
      unique: true,
    },
  ],
});

Column Reference

id
uuid
required
Auto-generated UUID primary key using PostgreSQL gen_random_uuid().
symbol
string
required
Trading pair symbol, e.g. "BTCUSDT". Part of the unique index.
interval
CandleInterval
required
Candle timeframe string (e.g. "1h"). Part of the unique index.
timestamp
bigint / number
required
Bar open time as epoch milliseconds. Stored as bigint; the epochTransformer ensures the JavaScript layer receives a plain number. Part of the unique index.
exchangeName
string
required
Exchange identifier. Hardcoded to "ccxt_binance" inside CandleDbService.
open
double precision
required
Opening price of the bar.
high
double precision
required
Highest price during the bar.
low
double precision
required
Lowest price during the bar.
close
double precision
required
Closing price of the bar.
volume
double precision
required
Trade volume during the bar.
createDate
timestamptz
required
Set automatically by TypeORM on first insert.
updatedDate
timestamptz
required
Updated automatically by TypeORM on every write (but OHLCV columns are never re-written).

Immutability: The No-Op Upsert

The CandleDbService.create() method performs a single atomic INSERT … ON CONFLICT … DO UPDATE … RETURNING *. The conflict target is the candle_items_uq index — the (symbol, interval, timestamp) triple that uniquely identifies a bar. Critically, the update clause only rewrites the symbol column to its own EXCLUDED.symbol value, which is a no-op:
const { raw } = await repo
  .createQueryBuilder()
  .insert()
  .values({
    symbol: dto.symbol,
    interval: dto.interval,
    timestamp: dto.timestamp,
    exchangeName: EXCHANGE_NAME,
    open: dto.open, high: dto.high,
    low: dto.low, close: dto.close,
    volume: dto.volume,
  })
  .orUpdate(["symbol"], ["symbol", "interval", "timestamp"])  // no-op: preserves OHLCV
  .returning("*")
  .execute();
const result = raw[0] as ICandleRow;
await this.candleCacheService.setCandleId(result);
Using DO NOTHING instead of DO UPDATE is not viable here because ON CONFLICT DO NOTHING cannot use RETURNING to return the conflicting row. Without RETURNING, a follow-up SELECT would be needed to seed the Redis cache — and on a cluster with read replicas, that SELECT could be routed to a lagging replica that has not yet received the commit. The no-op DO UPDATE avoids both the race condition and the extra round-trip.

Read Path and Redis Cache

findBySymbolIntervalTimestamp follows the standard two-level read path:
  1. Compute the Redis cache key from (symbol, interval, exchangeName, timestamp).
  2. On a cache hit, fetch by UUID primary key (O(1) on both Redis and PostgreSQL).
  3. On a miss, fall back to a full filter query on the unique index and backfill Redis.
The hasCandle helper performs the same Redis-first check without fetching the full row, making it safe to call in hot loops to skip redundant fetches from the exchange.
During backtests, warmCandles() pre-populates the table before the simulation loop starts. Once warm, every readCandlesData call resolves entirely from the Redis + PostgreSQL primary-key path — no exchange API calls are needed.

Methods Summary

Iterates the array and calls CandleDbService.create() for each element. Each call is an atomic INSERT … ON CONFLICT (symbol, interval, timestamp) DO UPDATE SET symbol = EXCLUDED.symbol RETURNING *. Already-existing bars are returned unchanged; new bars are inserted. The Redis cache is updated after every call.
Walks limit steps starting from sinceTimestamp, incrementing by the interval’s millisecond step size each time. Returns a CandleData[] array if all bars are present, or null if any single bar is missing. A null result signals backtest-kit to fetch the missing range from the exchange.
Called by backtest-kit when a new adapter instance is first touched. If initial is true, calls waitForInfra() to ensure PostgreSQL and Redis are ready. The singleshot wrapper guarantees this async gate runs at most once across all 16 adapters.

Build docs developers (and LLMs) love