Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/theonetrade/backtest-monorepo-parallel/llms.txt

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

BaseCRUD is a di-factory factory that wraps any Mongoose model with a complete CRUD surface — adding a new collection follows the same three-step pattern established by CandleDbService. You define the schema, extend BaseCRUD, and register the service in the DI container. The factory provides create, update, findById, findByFilter, findAll, iterate, and paginate at no additional cost, and you override only the methods that need custom atomicity semantics.
1

Schema — define the Mongoose model

Create a schema file under packages/core/src/schema/<Name>.schema.ts. Follow the Candle.schema.ts pattern exactly: define three exports — a DTO interface for write inputs, a Document interface for the Mongoose layer, and a Row interface for read outputs (with id, timestamps, and any derived fields).Here is the complete Candle.schema.ts as the canonical reference:
// packages/core/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" } }
);

// Compound unique index — shape must match the upsert filter in the service
CandleSchema.index({ symbol: 1, interval: 1, timestamp: 1 }, { unique: true });

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

export { CandleModel, ICandleDto, ICandleRow };
Key rules to follow in your own schema:
  • Enable timestamps with createdAt/updatedAt mapped to human-readable field names (createDate, updatedDate).
  • Define a compound unique index whose fields exactly match the filter you will use in the findOneAndUpdate upsert — this is what makes concurrent writes safe across 9 parallel symbol runners.
  • Export the Model, the Dto interface (write input), and the Row interface (read output with id and dates).
2

Service — extend BaseCRUD

Create packages/core/src/lib/services/db/<Name>DbService.ts. Call BaseCRUD(YourModel) as the base class — the factory mixes in all standard CRUD methods automatically.Here is the complete CandleDbService as the canonical reference:
// packages/core/src/lib/services/db/CandleDbService.ts
import BaseCRUD from "../../common/BaseCRUD";
import { ICandleDto, ICandleRow, CandleModel } from "../../../schema/Candle.schema";
import { readTransform } from "../../../utils/readTransform";
import { inject } from "../../core/di";
import { TYPES } from "../../core/types";
import { LoggerService } from "../base/LoggerService";
import { CandleInterval } from "backtest-kit";

const EXCHANGE_NAME = "ccxt_binance";

export class CandleDbService extends BaseCRUD(CandleModel) {
  readonly loggerService = inject<LoggerService>(TYPES.loggerService);

  // Override create() with an atomic upsert — never throws E11000 under concurrency
  public create = async (dto: ICandleDto): Promise<ICandleRow> => {
    this.loggerService.log("candleDbService create", { dto });
    const filter = {
      symbol:    dto.symbol,
      interval:  dto.interval,
      timestamp: dto.timestamp,
    };
    const insertOnly = {
      exchangeName: EXCHANGE_NAME,
      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 },
    );
    return readTransform(document.toJSON()) as unknown as ICandleRow;
  };

  // Additional domain methods layered on top of the BaseCRUD surface
  public hasCandle = async (
    symbol: string,
    interval: CandleInterval,
    timestamp: number,
  ): Promise<boolean> => {
    const candle = await this.findBySymbolIntervalTimestamp(symbol, interval, timestamp);
    return !!candle;
  };

  public findBySymbolIntervalTimestamp = async (
    symbol: string,
    interval: CandleInterval,
    timestamp: number,
  ): Promise<ICandleRow | null> => {
    return await super.findByFilter({ symbol, interval, exchangeName: EXCHANGE_NAME, timestamp });
  };
}

export default CandleDbService;
The methods you receive from BaseCRUD without any override:
MethodSignature
create(dto: object) => Promise<Row>
update(id: string, dto: object) => Promise<Row>
findById(id: string) => Promise<Row>
findByFilter(filter: object, sort?: object) => Promise<Row | null>
findAll(filter?: object, limit?: number) => Promise<Row[]>
iterate(filter?: object, sort?: object) => AsyncGenerator<Row>
paginate(filter: object, pagination: { limit: number; offset: number }, sort?: object) => Promise<{ rows: Row[]; total: number }>
Override only the methods that require custom logic — every other method falls through to the BaseCRUD implementation.
3

Register — wire into the DI container

Follow the full five-step recipe from Adding a New Service to register the new DbService:
  1. The file already exists from the previous step.
  2. Add nameDbService: Symbol('nameDbService') to the dbServices group in packages/core/src/lib/core/types.ts.
  3. Add provide(TYPES.nameDbService, () => new NameDbService()) to packages/core/src/lib/core/provide.ts.
  4. Add nameDbService: inject<NameDbService>(TYPES.nameDbService) to the dbServices group in packages/core/src/lib/index.ts.
  5. Run npm run build to regenerate types.d.ts.
After the build, core.nameDbService is globally typed and accessible from strategy files.

The upsert pattern explained

CandleDbService overrides create() to use findOneAndUpdate with $setOnInsert rather than a plain Model.create(). This is the correct atomicity contract for the parallel runner:
const document = await CandleModel.findOneAndUpdate(
  filter,                                             // match by compound unique index
  { $setOnInsert: insertOnly },                       // write ONLY on first insert
  { upsert: true, new: true, setDefaultsOnInsert: true },
);
  • upsert: true — create the document if it does not exist; update if it does.
  • $setOnInsert — the payload is only applied when the document is being inserted for the first time. If all 9 symbol runners try to write the same candle concurrently, the first one wins and the rest are no-ops — no data is overwritten, no E11000 duplicate key errors are thrown.
  • new: true — return the document as it exists after the operation (inserted or found).
  • setDefaultsOnInsert: true — apply schema defaults when inserting.
Always use $setOnInsert (not $set) when the document already exists and you do not want to overwrite existing data. Use $set only for fields that should update on every call — for example, a lastSeenAt timestamp or a running count field that is genuinely meant to change on re-encounter.

Build docs developers (and LLMs) love