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.

Measure, Interval, and Memory share a soft-delete pattern instead of hard-deleting rows. When a key is removed, a single server-side UPDATE sets removed = true and flips a removed flag inside the payload JSONB blob via jsonb_set — no row is ever physically deleted from the database. The listKeys / listEntries queries filter on removed = false to skip tombstones. This mirrors the semantics of the default file-based PersistMeasureInstance, PersistIntervalInstance, and PersistMemoryInstance.
Soft-delete is implemented with a single server-side UPDATE … SET removed = true, payload = jsonb_set("payload", '{removed}', 'true') WHERE … RETURNING *. There is no read-modify-write cycle, so a concurrent upsert that arrives while a remove is in flight cannot produce a lost update.

Measure Adapter

The Measure adapter is a general-purpose key/value cache for expensive computations — LLM API responses, parsed data, or any result that should be fetched once and reused. It is intentionally exempt from look-ahead bias protection: its writeMeasureData signature accepts a _when: Date argument but discards it, and the schema has no when column.

Context Key and Table

FieldTypeRole
bucketstringNamespace grouping related keys
entryKeystringKey within the bucket — passed at read/write/remove time
Table: measure-items — unique index measure_items_uq on (bucket, entryKey)

Registration

PersistMeasureAdapter.usePersistMeasureAdapter(class implements IPersistMeasureInstance {
  constructor(readonly bucket: string) {}

  async waitForInit(initial: boolean) {
    if (!initial) return;
    await waitForInfra();
  }

  async readMeasureData(key: string): Promise<MeasureData | null> {
    const row = await ioc.measureDbService.findByKey(this.bucket, key);
    if (!row || row.removed) return null;
    return row.payload;
  }

  async writeMeasureData(data: MeasureData, key: string, _when: Date): Promise<void> {
    await ioc.measureDbService.upsert(this.bucket, key, data);
  }

  async removeMeasureData(key: string): Promise<void> {
    await ioc.measureDbService.softRemove(this.bucket, key);
  }

  async *listMeasureData(): AsyncGenerator<string> {
    const keys = await ioc.measureDbService.listKeys(this.bucket);
    for (const key of keys) {
      yield key;
    }
  }
});

Table Schema

const MeasureModel = new EntitySchema<IMeasureRow>({
  name: "measure-items",
  columns: {
    id:         { type: "uuid", primary: true, generated: "uuid" },
    bucket:     { type: String },
    entryKey:   { type: String },
    payload:    { type: "jsonb" },
    removed:    { type: "boolean", default: false },
    createDate: { type: "timestamptz", createDate: true },
    updatedDate: { type: "timestamptz", updateDate: true },
  },
  indices: [
    { name: "measure_items_uq", columns: ["bucket", "entryKey"], unique: true },
  ],
});

MeasureDbService.softRemove — Atomic Soft-Delete

public softRemove = async (bucket: string, entryKey: string): Promise<void> => {
  const repo = await this.repo<IMeasureRow>();
  const { raw } = await repo
    .createQueryBuilder()
    .update()
    .set({
      removed: true,
      payload: () => `jsonb_set("payload", '{removed}', 'true')`,
    })
    .where({ bucket, entryKey })
    .returning("*")
    .execute();
  const saved = raw[0] as IMeasureRow | undefined;
  if (!saved) return;
  await this.measureCacheService.setMeasureId(saved);  // update Redis with tombstone row
};

Column Reference

bucket
string
required
Namespace grouping multiple related entries. Part of the unique index.
entryKey
string
required
Key within the bucket. Part of the unique index.
payload
MeasureData (jsonb)
required
Cached data blob. MeasureData is the type exported by backtest-kit.
removed
boolean
required
Soft-delete tombstone flag. Default false. Set to true by softRemove. readMeasureData returns null when this is true.

Interval Adapter

The Interval adapter tracks once-per-interval markers — timestamps or flags that record whether a strategy has already acted during the current candle period, day, or other time window. Unlike Measure, Interval carries a when column for look-ahead bias protection, and its upsert includes when in the conflict-update clause.

Context Key and Table

FieldTypeRole
bucketstringNamespace grouping related interval keys
entryKeystringKey within the bucket — passed at read/write/remove time
Table: interval-items — unique index interval_items_uq on (bucket, entryKey)

Registration

PersistIntervalAdapter.usePersistIntervalAdapter(class implements IPersistIntervalInstance {
  constructor(readonly bucket: string) {}

  async waitForInit(initial: boolean) {
    if (!initial) return;
    await waitForInfra();
  }

  async readIntervalData(key: string): Promise<IntervalData | null> {
    const row = await ioc.intervalDbService.findByKey(this.bucket, key);
    if (!row || row.removed) return null;
    return row.payload;
  }

  async writeIntervalData(data: IntervalData, key: string, when: Date): Promise<void> {
    await ioc.intervalDbService.upsert(this.bucket, key, data, when);
  }

  async removeIntervalData(key: string): Promise<void> {
    await ioc.intervalDbService.softRemove(this.bucket, key);
  }

  async *listIntervalData(): AsyncGenerator<string> {
    const keys = await ioc.intervalDbService.listKeys(this.bucket);
    for (const key of keys) {
      yield key;
    }
  }
});

Table Schema

const IntervalModel = new EntitySchema<IIntervalRow>({
  name: "interval-items",
  columns: {
    id:         { type: "uuid", primary: true, generated: "uuid" },
    bucket:     { type: String },
    entryKey:   { type: String },
    payload:    { type: "jsonb" },
    removed:    { type: "boolean", default: false },
    when:       { type: "bigint", transformer: epochTransformer },
    createDate: { type: "timestamptz", createDate: true },
    updatedDate: { type: "timestamptz", updateDate: true },
  },
  indices: [
    { name: "interval_items_uq", columns: ["bucket", "entryKey"], unique: true },
  ],
});

IntervalDbService.softRemove — Atomic Soft-Delete

public softRemove = async (bucket: string, entryKey: string): Promise<void> => {
  const repo = await this.repo<IIntervalRow>();
  const { raw } = await repo
    .createQueryBuilder()
    .update()
    .set({
      removed: true,
      payload: () => `jsonb_set("payload", '{removed}', 'true')`,
    })
    .where({ bucket, entryKey })
    .returning("*")
    .execute();
  const saved = raw[0] as IIntervalRow | undefined;
  if (!saved) return;
  await this.intervalCacheService.setIntervalId(saved);
};
The clearBucket helper (not part of the IPersistIntervalInstance contract) physically deletes all rows for a bucket and evicts their Redis cache entries — useful for test teardown or bucket resets.

Column Reference

removed
boolean
required
Soft-delete tombstone flag. Default false.
when
bigint / number
required
Logical simulation timestamp at write time. Included in the orUpdate clause so every write refreshes the look-ahead bias timestamp alongside the payload.

Memory Adapter

The Memory adapter provides a per-signal key/value store scoped to (signalId, bucketName). Each entry within that scope is addressed by a memoryId. Unlike Measure and Interval — which yield plain string keys from their async generators — Memory’s generator yields { memoryId: string; data: MemoryData } objects so the caller receives both the key and the value in one pass. Memory also carries a when column for look-ahead bias protection.

Context Key and Table

FieldTypeRole
signalIdstringSignal scope — set in constructor
bucketNamestringSub-scope within the signal — set in constructor
memoryIdstringEntry key — passed at read/write/remove time
Table: memory-items — unique index memory_items_uq on (signalId, bucketName, memoryId)

Registration

PersistMemoryAdapter.usePersistMemoryAdapter(class implements IPersistMemoryInstance {
  constructor(
    readonly signalId: string,
    readonly bucketName: string,
  ) {}

  async waitForInit(initial: boolean) {
    if (!initial) return;
    await waitForInfra();
  }

  async readMemoryData(memoryId: string): Promise<MemoryData | null> {
    const row = await ioc.memoryDbService.findByMemoryId(
      this.signalId, this.bucketName, memoryId
    );
    if (!row || row.removed) return null;
    return row.payload;
  }

  async hasMemoryData(memoryId: string): Promise<boolean> {
    return await ioc.memoryDbService.hasMemoryEntry(
      this.signalId, this.bucketName, memoryId
    );
  }

  async writeMemoryData(data: MemoryData, memoryId: string, when: Date): Promise<void> {
    await ioc.memoryDbService.upsert(
      this.signalId, this.bucketName, memoryId, data, when
    );
  }

  async removeMemoryData(memoryId: string): Promise<void> {
    await ioc.memoryDbService.softRemove(
      this.signalId, this.bucketName, memoryId
    );
  }

  async *listMemoryData(): AsyncGenerator<{ memoryId: string; data: MemoryData }> {
    const rows = await ioc.memoryDbService.listEntries(this.signalId, this.bucketName);
    for (const row of rows) {
      yield { memoryId: row.memoryId, data: row.payload };
    }
  }

  dispose(): void { void 0; }   // no-op cleanup hook
});

Table Schema

const MemoryModel = new EntitySchema<IMemoryRow>({
  name: "memory-items",
  columns: {
    id:         { type: "uuid", primary: true, generated: "uuid" },
    signalId:   { type: String },
    bucketName: { type: String },
    memoryId:   { type: String },
    payload:    { type: "jsonb" },
    removed:    { type: "boolean", default: false },
    when:       { type: "bigint", transformer: epochTransformer },
    createDate: { type: "timestamptz", createDate: true },
    updatedDate: { type: "timestamptz", updateDate: true },
  },
  indices: [
    {
      name: "memory_items_uq",
      columns: ["signalId", "bucketName", "memoryId"],
      unique: true,
    },
  ],
});

hasMemoryData and Redis

hasMemoryData checks MemoryCacheService.hasMemoryEntryId first — a pure Redis EXISTS call — before falling back to a PostgreSQL findByFilter. This is the cheapest existence check available: it avoids fetching the full row when the caller only needs a boolean.

dispose() — No-Op

The dispose() lifecycle hook is a no-op (void 0). Memory entries survive for the lifetime of the process and are cleaned up only by explicit removeMemoryData calls from strategy code, not by garbage collection.

Comparison: Measure vs. Interval vs. Memory

  • Constructor field: bucket: string
  • Entry key: entryKey (string argument)
  • Table: measure-items
  • when column: ❌ exempt from look-ahead bias
  • Async generator yields: string (key only)
  • dispose(): n/a
All three adapters treat a soft-deleted entry as absent: read*Data returns null when row.removed === true. A subsequent write*Data to the same key will upsert a new row (with removed: false) over the tombstone, effectively un-deleting it.

Build docs developers (and LLMs) love