Skip to main content

Documentation Index

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

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

backtest-kit ships with a set of persistence interfaces — IPersistSignalInstance, IPersistMeasureInstance, and others — that determine how run-time state is stored and retrieved. Out of the box, the framework uses its own file-based implementation. This project replaces every adapter with MongoDB as the source of truth and Redis as an O(1) ID cache. When you need to persist a new kind of data (custom metrics, position metadata, audit trails), you add a new adapter that follows the same four-file pattern used for signals, measures, and the rest.

When to Add a New Adapter

New data shape

You have a domain object (e.g. a risk event, a fill record) that backtest-kit doesn’t model natively and you want it stored durably in Mongo.

Custom cache key

You need O(1) lookup by a compound key (symbol + strategy + exchange) and don’t want to pay a Mongo round-trip on every tick.

Extending an existing interface

The framework added a new IPersist* interface in a minor version and you want to wire it up to the shared infrastructure.

Per-mode overrides

Backtest, live, and paper modes need different retention policies for the same data type.

The Four-File Pattern

Every adapter in this project is built from four cooperating files. The example below walks through adding a hypothetical Widget adapter.
1

Mongoose schema

Define the Mongo document shape in a model file. The exact schema depends on your data, but every document should include the context triple (symbol, strategyName, exchangeName) so findByContext queries are efficient. A removed boolean flag enables the soft-delete pattern used by Measure, Interval, and Memory adapters when listing historical data:
// src/schema/Widget.schema.ts
import mongoose, { Schema, Document } from "mongoose";

export interface WidgetDocument extends Document {
  symbol: string;
  strategyName: string;
  exchangeName: string;
  payload: unknown;
  removed: boolean;
  createdAt: Date;
}

const WidgetSchema = new Schema<WidgetDocument>({
  symbol:       { type: String, required: true },
  strategyName: { type: String, required: true },
  exchangeName: { type: String, required: true },
  payload:      { type: Schema.Types.Mixed, required: true },
  removed:      { type: Boolean, default: false },
  createdAt:    { type: Date, default: Date.now },
});

export const WidgetModel = mongoose.model<WidgetDocument>("Widget", WidgetSchema);
The removed: true soft-delete flag is what allows listWidgetData operations to filter out logically deleted records without a hard deleteOne. Measure, Interval, and Memory adapters all rely on this pattern.
2

DbService extending BaseCRUD

Create a service class that extends BaseCRUD(WidgetModel). Implement upsert (write or overwrite by context) and findByContext (read by context triple). The base class provides the Mongo connection lifecycle; you add the domain-specific query logic:
// src/lib/services/db/WidgetDbService.ts
import BaseCRUD from "../../common/BaseCRUD";
import { WidgetModel, WidgetDocument } from "../../../schema/Widget.schema";

export class WidgetDbService extends BaseCRUD(WidgetModel) {
  async findByContext(
    symbol: string,
    strategyName: string,
    exchangeName: string
  ): Promise<WidgetDocument | null> {
    return WidgetModel.findOne({
      symbol,
      strategyName,
      exchangeName,
      removed: { $ne: true },
    }).exec();
  }

  async upsert(
    symbol: string,
    strategyName: string,
    exchangeName: string,
    payload: unknown
  ): Promise<void> {
    await WidgetModel.findOneAndUpdate(
      { symbol, strategyName, exchangeName },
      { $set: { payload, removed: false } },
      { upsert: true, new: true }
    ).exec();
  }

  async listWidgetData(
    symbol: string,
    strategyName: string,
    exchangeName: string
  ): Promise<WidgetDocument[]> {
    return WidgetModel.find({
      symbol,
      strategyName,
      exchangeName,
      removed: { $ne: true },
    }).exec();
  }
}
3

CacheService extending BaseMap

Create a Redis-backed cache service by extending BaseMap(REDIS_KEY, -1). The second argument is the TTL in seconds; -1 means no expiry, which is correct for ID caches that must survive process restarts. Expose typed setXxxId, getXxxId, and hasXxxId helpers so callers never manipulate raw Redis keys:
// src/lib/services/cache/WidgetCacheService.ts
import BaseMap from "../../common/BaseMap";

const REDIS_KEY = "widget_cache";

export class WidgetCacheService extends BaseMap(REDIS_KEY, -1) {
  private makeKey(
    symbol: string,
    strategyName: string,
    exchangeName: string
  ): string {
    return `${symbol}:${strategyName}:${exchangeName}`;
  }

  async setWidgetId(
    symbol: string,
    strategyName: string,
    exchangeName: string,
    id: string
  ): Promise<void> {
    await this.set(this.makeKey(symbol, strategyName, exchangeName), id);
  }

  async getWidgetId(
    symbol: string,
    strategyName: string,
    exchangeName: string
  ): Promise<string | null> {
    return this.get(this.makeKey(symbol, strategyName, exchangeName));
  }

  async hasWidgetId(
    symbol: string,
    strategyName: string,
    exchangeName: string
  ): Promise<boolean> {
    return this.has(this.makeKey(symbol, strategyName, exchangeName));
  }
}
4

Registration in setup.ts

Implement the IPersistWidgetInstance interface and register the class with PersistWidgetAdapter.usePersistWidgetAdapter(). The waitForInit guard ensures that Mongo and Redis are both ready before any reads or writes occur. The initial flag is true only for the very first call, so the infrastructure wait runs exactly once per process:
// src/config/setup.ts (excerpt — Widget adapter registration)
import { PersistWidgetAdapter, IPersistWidgetInstance } from "backtest-kit";
import ioc from "../lib";
import { singleshot } from "functools-kit";

const waitForInfra = singleshot(async () => {
  await Promise.all([
    ioc.mongoService.waitForInit(),
    ioc.redisService.waitForInit(),
  ]);
});

PersistWidgetAdapter.usePersistWidgetAdapter(class implements IPersistWidgetInstance {
  constructor(
    readonly symbol: string,
    readonly strategyName: string,
    readonly exchangeName: string
  ) {}

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

  async readWidgetData() {
    const row = await ioc.widgetDbService.findByContext(
      this.symbol,
      this.strategyName,
      this.exchangeName
    );
    return row ? row.payload : null;
  }

  async writeWidgetData(data: unknown) {
    await ioc.widgetDbService.upsert(
      this.symbol,
      this.strategyName,
      this.exchangeName,
      data
    );
  }
});
The real Signal adapter in src/config/setup.ts follows this exact structure:
// src/config/setup.ts (Signal adapter — actual implementation)
import { PersistSignalAdapter, IPersistSignalInstance } from "backtest-kit";
import ioc from "../lib";
import { singleshot } from "functools-kit";

const waitForInfra = singleshot(async () => {
  await Promise.all([
    ioc.mongoService.waitForInit(),
    ioc.redisService.waitForInit(),
  ]);
});

PersistSignalAdapter.usePersistSignalAdapter(class implements IPersistSignalInstance {
  constructor(
    readonly symbol: string,
    readonly strategyName: string,
    readonly exchangeName: string
  ) {}

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

  async readSignalData() {
    const row = await ioc.signalDbService.findByContext(
      this.symbol,
      this.strategyName,
      this.exchangeName
    );
    return row ? row.payload : null;
  }

  async writeSignalData(signalRow) {
    await ioc.signalDbService.upsert(
      this.symbol,
      this.strategyName,
      this.exchangeName,
      signalRow
    );
  }
});

Wiring DI Tokens

After creating the service classes, expose them through the project’s inversion-of-control container in three places.

1. Add tokens to src/lib/core/types.ts

DI tokens are Symbol values that serve as unique keys in the container. Add one for the new DbService and one for the CacheService:
// src/lib/core/types.ts (excerpt)
export const TYPES = {
  // ... existing tokens ...
  WidgetDbService:    Symbol('WidgetDbService'),
  WidgetCacheService: Symbol('WidgetCacheService'),
};

2. Add provide() calls in src/lib/core/provide.ts

Register both services using provide(token, factory) so the container constructs them once and shares the instance:
// src/lib/core/provide.ts (excerpt)
import { WidgetDbService }    from "../services/db/WidgetDbService";
import { WidgetCacheService } from "../services/cache/WidgetCacheService";
import { provide } from "./di";
import TYPES from "./types";

provide(TYPES.WidgetDbService,    () => new WidgetDbService());
provide(TYPES.WidgetCacheService, () => new WidgetCacheService());

3. Add inject() calls in src/lib/index.ts

Expose the services as typed properties on the ioc object so the rest of the codebase can access them without importing from types.ts directly. inject() is lazy — it resolves the instance at access time, not at construction time:
// src/lib/index.ts (excerpt)
import { WidgetDbService }    from "./services/db/WidgetDbService";
import { WidgetCacheService } from "./services/cache/WidgetCacheService";
import { inject } from "./core/di";
import TYPES from "./core/types";

export const ioc = {
  // ... existing services ...
  widgetDbService:    inject<WidgetDbService>(TYPES.WidgetDbService),
  widgetCacheService: inject<WidgetCacheService>(TYPES.WidgetCacheService),
};

The waitForInfra() Gate

The singleshot wrapper from functools-kit turns the infrastructure wait into a one-shot promise. The first call to waitForInfra() actually awaits Mongo and Redis connectivity; every subsequent call returns the already-resolved promise immediately:
import { singleshot } from "functools-kit";

const waitForInfra = singleshot(async () => {
  await Promise.all([
    ioc.mongoService.waitForInit(),
    ioc.redisService.waitForInit(),
  ]);
});
Inside waitForInit(initial: boolean), the initial flag short-circuits the gate for every adapter instance that isn’t the first constructed. This means the infrastructure handshake happens exactly once per process regardless of how many symbols the runner processes in parallel:
async waitForInit(initial: boolean) {
  if (!initial) return;   // skip for every instance after the first
  await waitForInfra();   // blocks until Mongo + Redis are ready
}
Do not call waitForInfra() unconditionally (without the if (!initial) return guard). Doing so would serialize the waitForInit calls for every symbol, introducing unnecessary latency at startup when running large symbol lists.

Soft-Delete Pattern

Measure, Interval, and Memory adapters use a removed: true field for logical deletion rather than hard deleteOne calls. When implementing listXxxData for any adapter that needs to return a collection of historical records, always filter out soft-deleted documents:
async listWidgetData(symbol: string, strategyName: string, exchangeName: string) {
  return WidgetModel.find({
    symbol,
    strategyName,
    exchangeName,
    removed: { $ne: true },   // exclude soft-deleted records
  }).exec();
}
For single-record adapters (like Signal), the removed flag is not strictly necessary, but including it keeps the schema consistent with the rest of the project and makes future migrations easier.
Add a compound index on { symbol, strategyName, exchangeName, removed } to every Mongoose schema that will be queried by findByContext or listXxxData. Without it, Mongo will do a full collection scan on large datasets.

Build docs developers (and LLMs) love