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.

PostgreSQL B-tree lookups on an indexed compound key are fast — O(log n) — but backtest-kit performs thousands of read-by-context-key per second during backtests. At that volume, even sub-millisecond index scans accumulate into meaningful overhead. Redis turns every context-key lookup into an O(1) GET, bringing steady-state read latency down to a single network round-trip per call regardless of table size.

What the Cache Stores

The cache stores only the row’s UUID (id), never the document itself. A typical entry looks like:
signal_cache:binance:Jan2026Strategy:TRXUSDT  →  "a3f7c2e1-8b4d-4e9a-bc12-0f3a5d6e7f89"
Keeping the cache lean has two benefits: the cache footprint stays constant per row (a UUID string is always 36 bytes), and there is never a stale-document problem — the document lives only in Postgres, and a cache hit requires one more PK lookup to fetch the current document.

BaseMap: The ioredis Wrapper

BaseMap is a factory-generated class that accepts a connectionKey (namespace prefix) and a ttlExpireSeconds parameter. Every cache service in the project extends BaseMap with a fixed key and a TTL of -1 (no expiry), because identity caches must survive the full lifetime of a backtest run.
// src/lib/common/BaseMap.ts (condensed)
export const BaseMap = factory(
  class BaseMap {
    readonly loggerService = inject<LoggerService>(TYPES.loggerService);

    constructor(
      readonly connectionKey: string,
      readonly ttlExpireSeconds: number = DEFAULT_TTL_EXPIRE_SECONDS  // 5 minutes default
    ) {}

    _getItemKey(key: string): string {
      return `${this.connectionKey}:${key}`;  // namespaced key
    }

    async set(key: string, value: unknown): Promise<void> {
      const redis = await getRedis();
      const itemKey = this._getItemKey(key);
      await redis.set(itemKey, value as string);
      if (this.ttlExpireSeconds !== -1) {
        await redis.expire(itemKey, this.ttlExpireSeconds);
      }
    }

    async get(key: string | null): Promise<unknown | null> {
      if (key === null) return null;
      const redis = await getRedis();
      return await redis.get(this._getItemKey(key)) ?? null;
    }

    async delete(key: string): Promise<void> { /* redis.del */ }
    async has(key: string): Promise<boolean>  { /* redis.exists */ }
    async clear(): Promise<void>              { /* SCAN + DEL batch */ }
    async toArray(): Promise<[string, unknown][]> { /* SCAN + MGET */ }
    async *iterate(): AsyncIterableIterator<readonly [string, unknown]> { /* SCAN + MGET */ }
    async *keys(): AsyncIterableIterator<string>   { /* SCAN */ }
    async *values(): AsyncIterableIterator<unknown> { /* SCAN + MGET */ }
    async size(): Promise<number>             { /* SCAN + count */ }
  }
);

Namespaced keys

Every key is prefixed with ${connectionKey}: so multiple cache services can coexist in the same Redis instance without key collisions. For example, signal_cache:* and candle_cache:* are entirely separate key spaces.

TTL = -1 for identity caches

Passing -1 as ttlExpireSeconds skips the EXPIRE call entirely. Identity caches (all 16 domain cache services) use -1 — entries persist until the Redis server restarts or clear() is called explicitly.

SCAN-based iteration

toArray, keys, values, size, and clear all use cursor-based SCAN with a batch size of 100 instead of KEYS, avoiding O(n) blocking on the Redis event loop for large key sets.

Default 5-minute TTL

The DEFAULT_TTL_EXPIRE_SECONDS constant is 5 minutes (300 s). Any BaseMap subclass that does not pass a TTL argument gets auto-expiring keys — useful for transient caches that should not grow unbounded.

SignalCacheService: A Concrete Example

Each domain cache service extends BaseMap with a typed key-builder and domain-specific get/set methods. Here is SignalCacheService in full:
// src/lib/services/cache/SignalCacheService.ts
const REDIS_KEY = "signal_cache";

export class SignalCacheService extends BaseMap(REDIS_KEY, -1) {  // -1 = no TTL
  readonly loggerService = inject<LoggerService>(TYPES.loggerService);

  private _cacheKey(
    symbol: string,
    strategyName: string,
    exchangeName: string,
  ): string {
    return `${exchangeName}:${strategyName}:${symbol}`;
  }

  public async hasSignalId(
    symbol: string, strategyName: string, exchangeName: string,
  ): Promise<boolean> {
    return await this.has(this._cacheKey(symbol, strategyName, exchangeName));
  }

  public async getSignalId(
    symbol: string, strategyName: string, exchangeName: string,
  ): Promise<string | null> {
    const id = <string>await super.get(this._cacheKey(symbol, strategyName, exchangeName));
    return id ?? null;
  }

  public async setSignalId(row: ISignalRowDoc): Promise<string> {
    await super.set(
      this._cacheKey(row.symbol, row.strategyName, row.exchangeName),
      row.id,
    );
    return row.id;
  }
}
The full Redis key for a signal entry is composed as:
signal_cache:{exchangeName}:{strategyName}:{symbol}
For example: signal_cache:binance:Jan2026Strategy:TRXUSDT → "a3f7c2e1-…".

The Read Path in Detail

SignalDbService.findByContext implements the complete cache-aware read path:
// src/lib/services/db/SignalDbService.ts
public findByContext = async (
  symbol: string,
  strategyName: string,
  exchangeName: string,
): Promise<ISignalRowDoc | null> => {
  // 1. Try the cache first (O(1) Redis GET)
  const cachedId = await this.signalCacheService.getSignalId(
    symbol, strategyName, exchangeName,
  );

  if (cachedId) {
    // 2a. Cache hit: fetch full row by primary key (O(1) PK lookup)
    const cached = await super.findByFilter({ id: cachedId }) as ISignalRowDoc | null;
    if (cached) {
      return cached;
    }
    // cached is null if the row was physically deleted — fall through to filter
  }

  // 2b. Cache miss: full Postgres filter on the compound index
  const result = await super.findByFilter({
    symbol, strategyName, exchangeName,
  }) as ISignalRowDoc | null;

  // 3. Backfill the cache so the next call is a hit
  if (result) {
    await this.signalCacheService.setSignalId(result);
  }

  return result;
};
Two operations, both O(1):
  1. redis.GET signal_cache:binance:Jan2026Strategy:TRXUSDT → returns UUID string
  2. SELECT * FROM "signal-items" WHERE id = $1 → primary-key index scan
The Postgres PK lookup is a B-tree scan on a uuid column with cardinality 1, which PostgreSQL executes in O(1) time regardless of table size.

Cold Start and Redis Restart

When Redis restarts, every cache key is lost. On the next findByContext call for any context key, getSignalId returns null, the code falls through to the Postgres filter, fetches the row, and calls setSignalId to repopulate the cache. The process repeats for each unique context key on first access.
A Redis restart causes zero data loss. All records remain in PostgreSQL. The only consequence is a temporary increase in Postgres load as the cache warms back up on first access per context key. Warm-up is complete within the first full pass of the backtest’s context key space.
The Redis connection is maintained with a 30-second keepalive ping, managed inside getRedis():
// src/config/redis.ts
export const getRedis = singleshot(() => {
  const redis = new Redis({
    host: CC_REDIS_HOST,
    port: CC_REDIS_PORT,
    username: CC_REDIS_USER,
    password: CC_REDIS_PASSWORD,
  });

  setInterval(async () => {
    await redis.ping();    // prevent idle timeout disconnect
  }, 30000);

  process.on("SIGINT", async () => {
    await redis.disconnect(false);
  });

  return redis;
});

Build docs developers (and LLMs) love