Signal persistence is the crash recovery system that enables production-grade live trading in backtest-kit. This system ensures that active trading positions and scheduled limit orders are never lost, even during catastrophic failures (process crashes, power loss, SIGKILL, OOM errors).
This page documents the persistence layer specifically for signal state management. For risk position persistence, see 12.3. For partial profit/loss state persistence, see 6.6. For the overall signal lifecycle state machine, see 8.1.
Key Characteristics:
waitForInit()The signal persistence system operates across two independent domains to maintain crash safety:
Signal persistence uses a hierarchical directory structure to isolate signals by strategy and symbol:
| Persistence Domain | Base Directory | File Pattern | Entity Type |
|---|---|---|---|
| Pending Signals | ./dump/data/signal/ |
{symbol}_{strategyName}/{symbol}.json |
ISignalRow | null |
| Scheduled Signals | ./dump/data/schedule/ |
{symbol}_{strategyName}/{symbol}.json |
IScheduledSignalRow | null |
Example File Paths:
./dump/data/signal/BTCUSDT_trend-following/BTCUSDT.json
./dump/data/schedule/ETHUSDT_breakout-strategy/ETHUSDT.json
File Contents Structure:
// Pending signal file (signal/BTCUSDT_trend-following/BTCUSDT.json)
{
"id": "abc123",
"position": "long",
"priceOpen": 50000,
"priceTakeProfit": 52000,
"priceStopLoss": 48000,
"minuteEstimatedTime": 120,
"symbol": "BTCUSDT",
"exchangeName": "binance",
"strategyName": "trend-following",
"scheduledAt": 1704067200000,
"pendingAt": 1704067200000,
"_isScheduled": false
}
// Scheduled signal file (schedule/BTCUSDT_breakout-strategy/BTCUSDT.json)
{
"id": "def456",
"position": "short",
"priceOpen": 51000,
"priceTakeProfit": 49000,
"priceStopLoss": 52000,
"minuteEstimatedTime": 180,
"symbol": "BTCUSDT",
"exchangeName": "binance",
"strategyName": "breakout-strategy",
"scheduledAt": 1704070800000,
"pendingAt": 1704070800000,
"_isScheduled": true
}
The persistence layer uses an atomic write pattern to guarantee crash-safe operations. This pattern ensures that files are never left in a partially-written state:
Atomic Write Guarantees:
.tmp before renamefsync() ensures data is flushed to disk before renameSignal persistence is tightly integrated with the ClientStrategy state machine. Writes occur automatically after state transitions:
Persistence Trigger Points:
| State Transition | Method Called | Persistence Action |
|---|---|---|
Idle → Scheduled |
setScheduledSignal(signal) |
Write scheduled signal to schedule/{strategy}/{symbol}.json |
Idle → Pending |
setPendingSignal(signal) |
Write pending signal to signal/{strategy}/{symbol}.json |
Scheduled → Pending |
setScheduledSignal(null) + setPendingSignal(signal) |
Clear schedule file, write signal file |
Scheduled → Idle |
setScheduledSignal(null) |
Write null to schedule file |
Pending → Closed |
setPendingSignal(null) |
Write null to signal file |
When a live trading process restarts after a crash, the waitForInit() method restores signal state from disk:
Validation During Initialization:
The PersistBase class performs automatic validation and cleanup during waitForInit():
mkdir(directory, { recursive: true }) ensures storage directories existexchangeName and strategyNameThese singleton utility classes provide the high-level API for signal persistence:
Purpose: Manages persistence for pending signals (active positions).
Key Methods:
| Method | Signature | Description |
|---|---|---|
readSignalData |
(symbol: string, strategy: StrategyName) => Promise<ISignalRow | null> |
Reads pending signal from disk. Returns null if no signal exists. |
writeSignalData |
(signal: ISignalRow | null, symbol: string, strategy: StrategyName) => Promise<void> |
Writes pending signal to disk using atomic writes. Pass null to clear. |
usePersistSignalAdapter |
(Ctor: TPersistBaseCtor) => void |
Registers custom persistence backend (e.g., Redis, MongoDB). |
Implementation Details:
PersistBase instance per symbol:strategyName combination./dump/data/signal/{symbol}_{strategyName}{symbol}.jsonPurpose: Manages persistence for scheduled signals (limit orders awaiting activation).
Key Methods:
| Method | Signature | Description |
|---|---|---|
readScheduleData |
(symbol: string, strategy: StrategyName) => Promise<IScheduledSignalRow | null> |
Reads scheduled signal from disk. Returns null if no scheduled signal exists. |
writeScheduleData |
(signal: IScheduledSignalRow | null, symbol: string, strategy: StrategyName) => Promise<void> |
Writes scheduled signal to disk using atomic writes. Pass null to clear. |
usePersistScheduleAdapter |
(Ctor: TPersistBaseCtor) => void |
Registers custom persistence backend (e.g., Redis, MongoDB). |
Implementation Details:
PersistBase instance per symbol:strategyName combination./dump/data/schedule/{symbol}_{strategyName}{symbol}.jsonThe ClientStrategy class integrates signal persistence through two private methods:
// Method signature (conceptual)
async setPendingSignal(signal: ISignalRow | null): Promise<void>
Behavior:
_pendingSignal statePersistSignalAdapter.writeSignalData(signal, symbol, strategy)callbacks.onWrite(symbol, signal, backtest) for testingCalled By:
OPEN_NEW_PENDING_SIGNAL_FN() - When immediate signal opensACTIVATE_SCHEDULED_SIGNAL_FN() - When scheduled signal activatesCLOSE_PENDING_SIGNAL_FN() - When signal closes (writes null)// Method signature (conceptual)
async setScheduledSignal(signal: IScheduledSignalRow | null): Promise<void>
Behavior:
_scheduledSignal statePersistScheduleAdapter.writeScheduleData(signal, symbol, strategy)callbacks.onWrite(symbol, signal, backtest) for testingCalled By:
GET_SIGNAL_FN() - When scheduled signal is generatedACTIVATE_SCHEDULED_SIGNAL_FN() - Clears scheduled signal (writes null) before activationCANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN() - Clears scheduled signal on cancellationCHECK_SCHEDULED_SIGNAL_TIMEOUT_FN() - Clears scheduled signal on timeoutSignal persistence behavior differs dramatically between execution modes:
| Aspect | Backtest Mode | Live Mode |
|---|---|---|
| Persistence Enabled | ❌ No | ✅ Yes |
| Reason | Performance optimization | Crash recovery |
| State Storage | In-memory only | File system |
| waitForInit() | Skips disk reads | Restores from disk |
| setPendingSignal() | Updates memory only | Writes to disk |
| setScheduledSignal() | Updates memory only | Writes to disk |
| File I/O | Zero disk operations | Every state change |
| Crash Recovery | Not applicable (fast execution) | Full state restoration |
Determination Logic:
The backtest flag is propagated through ExecutionContextService:
// In Live mode
context.backtest = false; // Persistence enabled
// In Backtest mode
context.backtest = true; // Persistence disabled
Conditional Persistence Check:
// Inside setPendingSignal() and setScheduledSignal()
if (this.params.execution.context.backtest) {
return; // Skip persistence in backtest mode
}
// Continue with disk write in live mode
await PersistSignalAdapter.writeSignalData(signal, symbol, strategy);
The persistence layer supports custom adapters for alternative storage systems (Redis, MongoDB, S3, etc.):
Custom Adapter Example:
import { PersistBase, PersistSignalAdapter, EntityId, IEntity } from 'backtest-kit';
import Redis from 'ioredis';
class RedisPersist extends PersistBase {
private redis = new Redis();
async readValue<T extends IEntity>(entityId: EntityId): Promise<T> {
const data = await this.redis.get(`${this.entityName}:${entityId}`);
if (!data) throw new Error(`Entity ${this.entityName}:${entityId} not found`);
return JSON.parse(data) as T;
}
async writeValue<T extends IEntity>(entityId: EntityId, entity: T): Promise<void> {
const serialized = JSON.stringify(entity);
await this.redis.set(`${this.entityName}:${entityId}`, serialized);
}
async hasValue(entityId: EntityId): Promise<boolean> {
const exists = await this.redis.exists(`${this.entityName}:${entityId}`);
return exists === 1;
}
}
// Register custom backend
PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
// Now all signal persistence uses Redis instead of file system
The persistence layer includes robust error handling for file system operations:
During waitForInit(), the PersistBase class validates all JSON files:
for await (const key of this.keys()) {
try {
await this.readValue(key); // Attempt to parse JSON
} catch {
const filePath = this._getFilePath(key);
console.error(`Invalid document found: ${filePath}`);
// Retry deletion up to 5 times with 1-second delay
const deleted = await retry(
async () => fs.unlink(filePath),
BASE_UNLINK_RETRY_COUNT, // 5
BASE_UNLINK_RETRY_DELAY // 1000ms
);
if (!deleted) {
console.error(`Failed to remove corrupted file: ${filePath}`);
}
}
}
Retry Parameters:
| Constant | Value | Description |
|---|---|---|
BASE_UNLINK_RETRY_COUNT |
5 | Maximum deletion attempts |
BASE_UNLINK_RETRY_DELAY |
1000ms | Delay between attempts |
All file operations include error handling with descriptive messages:
// Read operation
try {
const fileContent = await fs.readFile(filePath, 'utf-8');
return JSON.parse(fileContent);
} catch (error: any) {
if (error?.code === 'ENOENT') {
throw new Error(`Entity ${this.entityName}:${entityId} not found`);
}
throw new Error(`Failed to read entity: ${getErrorMessage(error)}`);
}
// Write operation
try {
const serialized = JSON.stringify(entity);
await writeFileAtomic(filePath, serialized, 'utf-8');
} catch (error) {
throw new Error(`Failed to write entity: ${getErrorMessage(error)}`);
}
Signal persistence is optimized for live trading scenarios:
Both PersistSignalAdapter and PersistScheduleAdapter use memoization to cache PersistBase instances:
// One PersistBase instance per symbol:strategy combination
private getSignalStorage = memoize(
([symbol, strategyName]) => `${symbol}:${strategyName}`,
(symbol, strategy) => new PersistBase(`${symbol}_${strategy}`, './dump/data/signal/')
);
Benefits:
Each strategy-symbol combination has its own directory to prevent I/O contention:
./dump/data/signal/BTCUSDT_strategy-a/BTCUSDT.json
./dump/data/signal/BTCUSDT_strategy-b/BTCUSDT.json
./dump/data/signal/ETHUSDT_strategy-a/ETHUSDT.json
Benefits:
Backtest mode completely bypasses persistence for maximum performance:
// Inside setPendingSignal()
if (this.params.execution.context.backtest) {
return; // Zero disk I/O in backtest mode
}
Impact: Backtests run 100x+ faster without persistence overhead.
The system includes callbacks for testing persistence behavior:
The IStrategyCallbacks interface includes an onWrite callback that fires after every persistence operation:
addStrategy({
strategyName: 'test-strategy',
interval: '1m',
getSignal: async () => { /* ... */ },
callbacks: {
onWrite: (symbol, data, backtest) => {
console.log(`Signal persisted: ${symbol}`, data);
// data can be ISignalRow, IScheduledSignalRow, or null
}
}
});
Use Cases:
The test suite includes comprehensive persistence tests:
File: test/e2e/persist.test.mjs (referenced in test/index.mjs:11)
Test Scenarios: