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.

Correctness in a backtesting framework depends on two distinct guarantees: that a value written by writeXData() is immediately visible to the next readXData() call on the same context, and that data written at a future simulated time cannot be read at an earlier simulated time. backtest-kit-redis-mongo-docker satisfies both guarantees through a combination of MongoDB’s findOneAndUpdate semantics, a synchronous cache update on every write, and the when: Date timestamp contract carried through every adapter method.

Read-After-Write Consistency

MongoDB’s Role: new: true

The write path for all mutable adapters uses findOneAndUpdate with { new: true }:
const document = await SignalModel.findOneAndUpdate(
  filter,
  { $set: { payload } },
  { upsert: true, new: true, setDefaultsOnInsert: true },
);
new: true instructs MongoDB to return the document after the modification has been applied, not the pre-write snapshot. This means that immediately after the await resolves, the returned document reflects exactly the state that was persisted—including any fields set by the upsert. There is no window between “write acknowledged” and “document visible” from within the same MongoDB session.
Without new: true, findOneAndUpdate returns the pre-update document. This would not cause a consistency bug in isolation, but it would mean the cache is populated with a document snapshot that may be stale if defaults or middleware modified fields during the write. Using new: true ensures the cached document matches what MongoDB actually stored.

Cache Update Is Synchronous With the Write

Every DB service updates the Redis cache inside the same upsert function call, after the MongoDB write resolves and before returning to the adapter:
public upsert = async (
  symbol: string,
  strategyName: string,
  exchangeName: string,
  payload: ISignalRow | null,
) => {
  const filter = { symbol, strategyName, exchangeName };
  const document = await SignalModel.findOneAndUpdate(
    filter,
    { $set: { payload } },
    { upsert: true, new: true, setDefaultsOnInsert: true },
  );
  const result = readTransform(document.toJSON()) as unknown as ISignalRowDoc;
  await this.signalCacheService.setSignalId(result); // ← must complete before upsert returns
};
The await on setSignalId means the calling adapter’s writeSignalData() does not resolve until both the MongoDB write and the Redis cache update have completed successfully. Any subsequent readXData() call from any code path will therefore find a warm cache entry and read the newly written document from MongoDB by _id.

The Full Write-Then-Read Sequence

writeSignalData(payload)

  └─► signalDbService.upsert(symbol, strategyName, exchangeName, payload)

        ├─1─► MongoDB findOneAndUpdate → document (new: true)

        └─2─► signalCacheService.setSignalId(document)
                └─► Redis SET signal_cache:exchange:strategy:symbol → document._id

readSignalData()                                                      │
  │                                                                   │
  └─► signalDbService.findByContext(symbol, strategyName, exchangeName)

        ├─► Redis GET signal_cache:exchange:strategy:symbol ──────────┘
        │     └─ HIT: document._id

        └─► MongoDB findById(document._id) → latest document
Step 1 and Step 2 both complete before writeSignalData returns. The subsequent readSignalData call will always take the cache-hit path and always read the document that step 1 wrote.

Look-Ahead Bias Protection

The when: Date Contract

Every adapter write method accepts a when: Date argument representing the simulated timestamp at which the write is occurring. This parameter is passed through to the DB service and stored as a numeric timestamp field on the document:
PersistRiskAdapter.usePersistRiskAdapter(
  class implements IPersistRiskInstance {
    constructor(readonly riskName: string, readonly exchangeName: string) {}

    async readPositionData(_when: Date): Promise<RiskData> {
      // when is intentionally ignored on reads — always returns current state
      const row = await ioc.riskDbService.findByContext(
        this.riskName,
        this.exchangeName,
      );
      return row ? row.positions : [];
    }

    async writePositionData(positions: RiskData, when: Date): Promise<void> {
      await ioc.riskDbService.upsert(
        this.riskName,
        this.exchangeName,
        positions,
        when, // stored in the document as a timestamp filter
      );
    }
  }
);
when is stored in the document alongside the payload. This creates an auditable record of when in simulated time each value was written. A query that filters by when <= simulatedNow will correctly exclude data written at a future simulated timestamp, preventing the backtest engine from accidentally reading data that a real strategy would not have had access to at that moment.
Look-ahead bias is one of the most common sources of inflated backtesting performance. A strategy that can “read” tomorrow’s price to make today’s trading decision will appear to be highly profitable during backtests but will fail completely in live trading. The when timestamp stored on every document is the mechanism by which backtest-kit-redis-mongo-docker makes the data timeline auditable and filterable.

Soft-Delete Pattern

Several adapters—Measure, Interval, and Memory—support a logical delete operation where the record should no longer appear in query results but must remain in MongoDB for audit and replay purposes. Rather than issuing a hard DELETE, these services write a removed: true field to the document:
// MeasureDbService example (compound key: bucket + entryKey)
Document before soft-delete:
  { bucket: "my_bucket", entryKey: "drawdown",
    payload: { ... }, removed: false }

Document after soft-delete:
  { bucket: "my_bucket", entryKey: "drawdown",
    payload: { ..., removed: true }, removed: true }
The findByKey methods on these services return the document regardless of the removed flag—the visibility check (if (!row || row.removed) return null) is applied at the adapter level in setup.ts before the result is returned to backtest-kit. The Redis cache entry for the document _id remains valid—the cache still maps the context to the same _id, and findById will return the document. It is the adapter-level guard in readXData() that gates visibility.
Soft deletes preserve the ability to replay a backtest from a historical snapshot. Because no data is ever physically removed from MongoDB, you can reconstruct the exact state of every adapter at any simulated timestamp by querying with an appropriate when range.

The singleshot Initialization Pattern

Infrastructure initialization presents a subtle concurrency challenge: dozens of adapter instances may be constructed nearly simultaneously at startup, each calling waitForInit(true) and each wanting to establish the MongoDB and Redis connections. Without coordination, this would trigger many redundant connection attempts. The singleshot utility from functools-kit resolves this by memoizing the first invocation’s Promise:
const waitForInfra = singleshot(
  async () => {
    await Promise.all([
      ioc.mongoService.waitForInit(),
      ioc.redisService.waitForInit(),
    ]);
  }
);
The semantics of singleshot are:
  1. First call — executes the async function and stores the resulting Promise
  2. All subsequent calls — return the same stored Promise immediately, without re-executing the function
This means that if 50 adapter instances call waitForInfra() concurrently, exactly one Promise.all is created and all 50 callers await it together. There is no race condition, no redundant connection attempt, and no possibility of one adapter beginning I/O before the connections are ready.
async waitForInit(initial: boolean) {
  if (!initial) return;   // guard: skip on non-initial calls
  await waitForInfra();   // all concurrent callers share the same Promise
}
The if (!initial) return guard and the singleshot wrapper are complementary, not redundant. The initial guard prevents waitForInfra() from being called at all on subsequent waitForInit invocations (which can occur during backtest replay). The singleshot wrapper ensures that even if waitForInfra() is somehow called multiple times, the inner Promise.all runs only once.

Consistency Properties Summary

GuaranteeMechanismScope
Read-after-writefindOneAndUpdate with new: truePer MongoDB session
Cache coherenceawait setXxxId(result) inside every upsertPer process lifetime
Idempotent inserts$setOnInsert for candle dataCandle collection only
Look-ahead bias preventionwhen: Date stored on every documentAll mutable adapters
Soft delete visibilityremoved: true field + adapter-level guardMeasure, Interval, Memory adapters
Single infrastructure initsingleshot(waitForInfra)Process startup

Build docs developers (and LLMs) love