Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/backtest-kit/backtest-kit-redis-mongo-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 in the candle-items MongoDB collection. Candle data is treated as immutable: the very first write for a given (symbol, interval, timestamp) triplet wins, and all subsequent write attempts for the same key are no-ops. Reads walk the candle series timestamp-by-timestamp, returning null for the whole batch if any bar is missing — ensuring backtest-kit always gets a gapless window or nothing at all.

IPersistCandleInstance implementation

The adapter is 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();
  }

  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;
  }
});
readCandlesData returns null as soon as any bar in the requested window is missing. This is intentional — backtest-kit expects a fully contiguous slice or nothing, to avoid silent gaps in indicator calculations.

Candle Mongoose schema

From src/schema/Candle.schema.ts:
import { CandleInterval } from "backtest-kit";
import mongoose, { Document, Schema } from "mongoose";

const INTERVAL_ENUM = [
  "1m", "3m", "5m", "15m", "30m",
  "1h", "2h", "4h", "6h", "8h", "1d",
] as const;

interface ICandleDto {
  symbol: string;
  interval: CandleInterval;
  timestamp: number;
  open: number;
  high: number;
  low: number;
  close: number;
  volume: number;
}

interface CandleDocument extends ICandleDto, Document {
  exchangeName: string;
}

interface ICandleRow extends ICandleDto {
  id: string;
  exchangeName: string;
  createDate: Date;
  updatedDate: Date;
}

const CandleSchema: Schema<CandleDocument> = new Schema(
  {
    symbol:       { type: String, required: true, index: true },
    interval:     { type: String, required: true, enum: INTERVAL_ENUM, index: true },
    timestamp:    { type: Number, required: true, index: true },
    exchangeName: { type: String, required: true, index: true },
    open:         { type: Number, required: true },
    high:         { type: Number, required: true },
    low:          { type: Number, required: true },
    close:        { type: Number, required: true },
    volume:       { type: Number, required: true },
  },
  { timestamps: { createdAt: "createDate", updatedAt: "updatedDate" } }
);

CandleSchema.index({ symbol: 1, interval: 1, timestamp: 1 }, { unique: true });

const CandleModel = mongoose.model<CandleDocument>("candle-items", CandleSchema);

$setOnInsert — first write wins

CandleDbService.create uses $setOnInsert with upsert: true. MongoDB only applies $setOnInsert fields when a document is actually inserted (i.e. no existing document matched the filter). If a document with the same (symbol, interval, timestamp) already exists, the operation becomes a no-op and the existing data is left untouched:
public create = async (dto: ICandleDto): Promise<ICandleRow> => {
  const filter = {
    symbol:    dto.symbol,
    interval:  dto.interval,
    timestamp: dto.timestamp,
  };
  const insertOnly = {
    exchangeName: EXCHANGE_NAME,  // hardcoded to "ccxt_binance"
    open:   dto.open,
    high:   dto.high,
    low:    dto.low,
    close:  dto.close,
    volume: dto.volume,
  };
  const document = await CandleModel.findOneAndUpdate(
    filter,
    { $setOnInsert: insertOnly },
    { upsert: true, new: true, setDefaultsOnInsert: true },
  );
  const result = readTransform(document.toJSON()) as unknown as ICandleRow;
  await this.candleCacheService.setCandleId(result);
  return result;
};
This guarantees historical bar integrity: a candle ingested from a data feed can never be silently overwritten by a later, potentially stale, re-delivery of the same bar.

Redis cache for findBySymbolIntervalTimestamp

findBySymbolIntervalTimestamp first asks Redis for the cached MongoDB _id. On a cache hit it goes straight to findById (an indexed _id lookup). On a miss it falls back to a full filter query and then primes the cache:
public findBySymbolIntervalTimestamp = async (
  symbol: string,
  interval: CandleInterval,
  timestamp: number,
): Promise<ICandleRow | null> => {
  try {
    const cachedId = await this.candleCacheService.getCandleId(
      symbol, interval, EXCHANGE_NAME, timestamp,
    );
    return await super.findById(cachedId) as ICandleRow | null;
  } catch {
    const result = await super.findByFilter({
      symbol, interval, exchangeName: EXCHANGE_NAME, timestamp,
    });
    if (result) {
      await this.candleCacheService.setCandleId(result);
    }
    return result;
  }
};

INTERVAL_MINUTES step mapping

readCandlesData converts the CandleInterval string to milliseconds using this lookup table, which is defined in src/config/setup.ts:
const MS_PER_MINUTE = 60_000;

const INTERVAL_MINUTES: Record<CandleInterval, number> = {
  "1m":  1,
  "3m":  3,
  "5m":  5,
  "15m": 15,
  "30m": 30,
  "1h":  60,
  "2h":  120,
  "4h":  240,
  "6h":  360,
  "8h":  480,
  "1d":  1440,
};
The step between consecutive bars is INTERVAL_MINUTES[interval] * MS_PER_MINUTE. Starting from sinceTimestamp, each iteration increments by one step to build the series.
The Candle adapter has no when: Date column. Because historical candles are immutable facts about past prices, there is no look-ahead bias concern — the data that existed at any point in time is exactly the data that will always exist for that bar.

Build docs developers (and LLMs) love