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.

Look-ahead bias occurs when a backtesting strategy reads data that would not have been available at the point in simulated time when the read occurs. In practice this means a strategy making a trading decision at, say, 09:00 on January 15 accidentally reading a position snapshot that was written at 11:30 on January 16 — data from the future. The result is a backtest that appears profitable under conditions that are impossible to replicate in live trading.

How backtest-kit 9.0+ Addresses It

Starting with backtest-kit 9.0, every adapter write method accepts a when: Date argument carrying the logical simulation timestamp at which the write occurs. For adapters that affect signal-affecting state — Risk, Partial, Breakeven, Recent, State, Session, Memory, and Interval — this timestamp is stored in the database alongside the payload. backtest-kit’s internal read filter uses the stored when value to verify that no read returns a row whose when is greater than the current simulation time. The adapters that do not carry a when column are the ones where temporal ordering is not meaningful:
  • Candle — OHLCV data is inherently ordered by timestamp (the market data column), not by write time.
  • Signal, Schedule, Strategy, Storage, Notification, Log — administrative or append-only records where look-ahead bias is not applicable.
  • Measure — intentionally exempt because it caches LLM/API responses, where temporal simulation ordering is not applicable.

The when Column: bigint + epochTransformer

The when column is stored in PostgreSQL as bigint (epoch milliseconds). This avoids timezone ambiguities and precision loss that can affect timestamptz columns at millisecond resolution. The pg Node.js driver returns bigint columns as strings, so a ValueTransformer is needed to keep the JS-visible value a plain number.
// src/utils/epochTransformer.ts

/**
 * Stores epoch-millisecond numbers in a Postgres `bigint` column while keeping
 * the JS-visible value a plain `number`. The `pg` driver returns `bigint` as a
 * string, so `from` parses it back; `to` passes the number through unchanged.
 */
export const epochTransformer: ValueTransformer = {
  to: (value: number | null | undefined) => value,
  from: (value: string | null | undefined) =>
    value === null || value === undefined ? value : Number(value),
};
The to function is a no-op — TypeORM passes the JS number directly to the pg driver, which serializes it as a PostgreSQL bigint literal. The from function converts the string that pg returns back into a number. This is applied transparently whenever TypeORM reads a when column.

State Schema: Full Entity Definition

StateModel is a representative example of a when-carrying entity. Its unique index is on (signalId, bucketName), and when is declared with epochTransformer:
// src/schema/State.schema.ts
const StateModel = new EntitySchema<IStateRow>({
  name: "state-items",
  columns: {
    id: { type: "uuid", primary: true, generated: "uuid" },
    signalId: { type: String },
    bucketName: { type: String },
    payload: { type: "jsonb" },                              // typed StateData
    when: { type: "bigint", transformer: epochTransformer }, // ms since epoch, read as number
    createDate: { type: "timestamptz", createDate: true },
    updatedDate: { type: "timestamptz", updateDate: true },
  },
  indices: [
    {
      name: "state_items_uq",
      columns: ["signalId", "bucketName"],
      unique: true,
    },
  ],
});
The payload column holds the domain-specific StateData object as jsonb. The when value is always stored as the millisecond epoch integer — e.g. 1737014400000 for 2026-01-16T12:00:00Z.

StateDbService: Storing when.getTime()

StateDbService.upsert converts the Date argument to milliseconds via when.getTime() before passing it to the query builder. This conversion is done inline in the values(...) call, so the atomic INSERT … ON CONFLICT … DO UPDATE … RETURNING * writes both the payload and the simulation timestamp in the same statement:
// src/lib/services/db/StateDbService.ts
public upsert = async (
  signalId: string,
  bucketName: string,
  payload: StateData,
  when: Date,
): Promise<void> => {
  const repo = await this.repo<IStateRow>();
  const { raw } = await repo
    .createQueryBuilder()
    .insert()
    .values({
      signalId,
      bucketName,
      payload,
      when: when.getTime(),     // Date → epoch milliseconds
    })
    .orUpdate(["payload", "when"], ["signalId", "bucketName"])  // both updated on conflict
    .returning("*")
    .execute();
  const result = raw[0] as IStateRow;
  await this.stateCacheService.setStateId(result);
};
Note that when is included in the orUpdate column list alongside payload. If a later simulation step writes a new state for the same (signalId, bucketName) context, both the payload and the simulation timestamp are updated atomically. This means the stored when always reflects the most recent logical write time.

Which Adapters Carry the when Column

Carry 'when'

Risk — active risk positions snapshotPartial — profit/loss levels per signalBreakeven — breakeven-reached flagRecent — last public signal per contextState — per-signal state bucketsSession — one session per running strategyMemory — per-signal memory store (soft-delete)Interval — once-per-interval markers (soft-delete)

Exempt from 'when'

Candle — market data ordered by timestampSignal — live signal state, no temporal filterSchedule — pending scheduled signalStrategy — persistent strategy stateStorage — closed/opened signal logNotification — event notificationsLog — strategy log entriesMeasure — LLM response cache (intentionally exempt)
Measure is intentionally exempt from look-ahead bias protection. It caches LLM or external API responses, which are keyed by content (a deterministic (bucket, entryKey) pair), not by simulation time. Applying a when filter to an LLM response cache would cause cache misses on every backtest replay, defeating its purpose.

The Interval Schema Example

IntervalModel follows the same when pattern, adding a removed: boolean column for soft-delete support:
// src/schema/Interval.schema.ts
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,
    },
  ],
});
When IntervalDbService.upsert is called with a when: Date, the value is converted inline: when: when.getTime(). The orUpdate list includes ["payload", "removed", "when"], so the simulation timestamp is always updated alongside the payload on conflict.

How the Filter Works End-to-End

1

Strategy calls writeIntervalData(data, key, when)

The adapt layer receives the simulation timestamp as a Date object and passes it to ioc.intervalDbService.upsert(bucket, key, data, when).
2

Epoch milliseconds stored atomically

IntervalDbService.upsert calls when.getTime() and includes the result in the INSERT … ON CONFLICT … DO UPDATE … RETURNING * statement. Both the payload and the when bigint are committed in one round-trip.
3

Read returns the stored when value

IntervalDbService.findByKey returns the full IIntervalRow, which includes when as a number (thanks to epochTransformer.from). backtest-kit reads this value and checks it against current_simulation_time.
4

backtest-kit's filter rejects future rows

If the returned row’s when exceeds the current simulation clock, backtest-kit treats the row as not yet written and returns null — the same result as if the write had not yet happened in simulation time.
The when filter is enforced by backtest-kit upstream of the persistence adapter, not inside the DbService itself. The DbService stores and returns the when value faithfully; the framework is responsible for rejecting values from the future. This separation means the persistence layer can be tested independently of the simulation clock.

Build docs developers (and LLMs) love