This document describes patterns for running trading strategies across multiple symbols (trading pairs) simultaneously. The framework is designed to support multi-symbol execution with automatic state isolation per symbol, shared strategy logic, and efficient memory usage through instance memoization.
For information about strategy implementation, see Strategy Configuration. For crash-safe persistence mechanics, see Signal Persistence. For report generation from multi-symbol execution, see Markdown Report Generation.
The framework's architecture naturally supports multi-symbol strategies through several key design patterns:
(strategyName, symbol) pair maintains separate signal stateClientStrategy instance serves multiple symbols via memoizationExecutionContextService injects the current symbol into each operationThe most common pattern for multi-symbol strategies is parallel execution using Promise.all(). Each symbol runs in its own async generator, yielding results independently.
import { Live } from "backtest-kit";
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
await Promise.all(
symbols.map(async (symbol) => {
for await (const result of Live.run(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
})) {
console.log(`[${symbol}] ${result.action}`);
if (result.action === "closed") {
console.log(`[${symbol}] PNL: ${result.pnl.pnlPercentage}%`);
}
}
})
);
Each (strategyName, symbol) pair maintains completely isolated signal state. This is enforced at the persistence layer, where signal files are stored per symbol.
./signals/
my-strategy/
BTCUSDT.json <- {signalRow: ISignalRow | null}
ETHUSDT.json <- {signalRow: ISignalRow | null}
SOLUSDT.json <- {signalRow: ISignalRow | null}
| Component | Isolation Mechanism |
|---|---|
PersistSignalUtils.readSignalData() |
Takes (strategyName, symbol) as parameters |
PersistSignalUtils.writeSignalData() |
Writes to ./signals/{strategyName}/{symbol}.json |
ClientStrategy._getFilename() |
Computes filename from executionContext.symbol |
ExecutionContextService.context |
Provides symbol for current operation |
The framework achieves memory efficiency by sharing ClientStrategy instances across symbols while maintaining isolated state through the ExecutionContextService.
| Service | Memoization Key | Instances Created |
|---|---|---|
StrategyConnectionService.getStrategy() |
strategyName |
1 per strategy |
ExchangeConnectionService.getExchange() |
exchangeName |
1 per exchange |
FrameConnectionService.getFrame() |
frameName |
1 per frame |
| Signal state persistence | (strategyName, symbol) |
1 file per symbol |
Memory Usage:
ClientStrategy instancesClientStrategy instance (shared)For multi-symbol strategies, background execution with event listeners provides cleaner separation of concerns. Each symbol runs independently while a central listener handles events.
import { Live, listenSignalLive } from "backtest-kit";
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
// Start all symbols in background
const cancellations = await Promise.all(
symbols.map((symbol) =>
Live.background(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
})
)
);
// Single listener for all symbols
listenSignalLive((event) => {
const symbol = event.signal?.symbol || "UNKNOWN";
if (event.action === "opened") {
console.log(`[${symbol}] Signal opened:`, event.signal.id);
}
if (event.action === "closed") {
console.log(`[${symbol}] Signal closed:`, {
pnl: event.pnl.pnlPercentage,
reason: event.closeReason
});
}
});
// Cancel all on exit
process.on("SIGINT", () => {
cancellations.forEach(cancel => cancel());
process.exit(0);
});
When running multiple symbols, the markdown report service aggregates all events for a given strategyName, regardless of symbol. Reports include symbol information in each row.
# Live Trading Report: my-strategy
Total events: 45 (15 per symbol × 3 symbols)
Closed signals: 9 (3 per symbol × 3 symbols)
Win rate: 66.67% (6W / 3L)
Average PNL: +1.45%
| Timestamp | Symbol | Action | Signal ID | Position | ... | PNL (net) | Close Reason |
|-----------|----------|--------|-----------|----------|-----|-----------|--------------|
| 12:00:00 | BTCUSDT | CLOSED | abc-123 | LONG | ... | +2.10% | take_profit |
| 12:01:00 | ETHUSDT | CLOSED | def-456 | SHORT | ... | +1.50% | take_profit |
| 12:02:00 | SOLUSDT | CLOSED | ghi-789 | LONG | ... | -0.80% | stop_loss |
Since reports aggregate all symbols, you can filter by symbol using event listeners:
import { listenSignalLive } from "backtest-kit";
const btcResults = [];
listenSignalLive((event) => {
if (event.signal?.symbol === "BTCUSDT" && event.action === "closed") {
btcResults.push(event);
}
});
// Later: generate custom report for BTCUSDT only
const btcWinRate = btcResults.filter(r => r.pnl.pnlPercentage > 0).length / btcResults.length;
console.log(`BTCUSDT Win Rate: ${(btcWinRate * 100).toFixed(2)}%`);
While parallel execution is most common, sequential execution can be useful for controlled resource usage or debugging.
import { Live } from "backtest-kit";
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
// Process one symbol at a time
for (const symbol of symbols) {
console.log(`Starting ${symbol}...`);
let signalCount = 0;
for await (const result of Live.run(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
})) {
if (result.action === "closed") {
signalCount++;
console.log(`[${symbol}] Signal ${signalCount} closed`);
// Move to next symbol after N signals
if (signalCount >= 10) {
console.log(`${symbol} completed 10 signals, moving to next`);
break;
}
}
}
}
console.log("All symbols processed sequentially");
| Pattern | Memory Usage | Execution Time | Use Case |
|---|---|---|---|
Parallel (Promise.all) |
Higher (all generators active) | Faster (concurrent) | Production multi-symbol trading |
| Sequential (for loop) | Lower (one generator active) | Slower (serial) | Debugging, resource-constrained environments |
Background (.background()) |
Medium (generators in background) | Fast (fire-and-forget) | Event-driven architectures |
For advanced strategies that need to coordinate decisions across symbols, use shared state outside the framework.
import { Live, listenSignalLive } from "backtest-kit";
// Shared state for cross-symbol coordination
const portfolio = {
totalPnL: 0,
activeSignals: new Set<string>(),
maxConcurrentSignals: 3,
};
// Listen to all signals and update shared state
listenSignalLive((event) => {
const symbol = event.signal?.symbol;
if (event.action === "opened") {
portfolio.activeSignals.add(event.signal.id);
console.log(`Active signals: ${portfolio.activeSignals.size}`);
}
if (event.action === "closed") {
portfolio.activeSignals.delete(event.signal.id);
portfolio.totalPnL += event.pnl.pnlPercentage;
console.log(`Portfolio PnL: ${portfolio.totalPnL.toFixed(2)}%`);
// Risk management: stop all if portfolio loss > 10%
if (portfolio.totalPnL < -10) {
console.error("Portfolio loss limit exceeded, stopping all symbols");
process.exit(1);
}
}
});
// Run all symbols
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
await Promise.all(
symbols.map((symbol) =>
Live.background(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
})
)
);
For strategies that need symbol data in getSignal(), use the ExecutionContextService:
import { addStrategy, getCandles } from "backtest-kit";
// Global state for cross-symbol analysis
const symbolData = new Map<string, { lastPrice: number }>();
addStrategy({
strategyName: "correlation-strategy",
interval: "5m",
getSignal: async (symbol) => {
// Get current symbol data
const candles = await getCandles(symbol, "1m", 1);
const currentPrice = candles[0].close;
// Update shared state
symbolData.set(symbol, { lastPrice: currentPrice });
// Decision based on other symbols
if (symbol === "BTCUSDT" && symbolData.has("ETHUSDT")) {
const ethPrice = symbolData.get("ETHUSDT").lastPrice;
const btcPrice = currentPrice;
const ratio = btcPrice / ethPrice;
// Trade BTC if ratio is unusual
if (ratio > 20) {
return {
position: "long",
priceOpen: currentPrice,
priceTakeProfit: currentPrice * 1.02,
priceStopLoss: currentPrice * 0.98,
minuteEstimatedTime: 60,
};
}
}
return null; // No signal
},
});
When one symbol encounters an error, the framework's design ensures other symbols continue operating independently.
import { Live } from "backtest-kit";
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
// Wrap each symbol in error boundary
await Promise.all(
symbols.map(async (symbol) => {
try {
for await (const result of Live.run(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
})) {
console.log(`[${symbol}]`, result.action);
}
} catch (error) {
console.error(`[${symbol}] ERROR:`, error.message);
// Log error but don't crash other symbols
await Live.dump("my-strategy", `./errors/${symbol}`);
// Optionally restart this symbol after delay
await new Promise(resolve => setTimeout(resolve, 5000));
// Recursively restart
}
})
);
Each symbol's state is persisted independently. If the entire process crashes:
./signals/my-strategy/BTCUSDT.json (restored)./signals/my-strategy/ETHUSDT.json (restored)./signals/my-strategy/SOLUSDT.json (restored)On restart, all symbols resume from their last persisted state with no signal duplication.
For strategies that need to dynamically enable/disable symbols based on conditions:
import { Live, listenSignalLive } from "backtest-kit";
const allSymbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "ADAUSDT"];
const activeSymbols = new Set(allSymbols.slice(0, 3)); // Start with first 3
// Track performance per symbol
const symbolStats = new Map(
allSymbols.map(s => [s, { wins: 0, losses: 0 }])
);
listenSignalLive((event) => {
if (event.action === "closed") {
const symbol = event.signal.symbol;
const stats = symbolStats.get(symbol);
if (event.pnl.pnlPercentage > 0) {
stats.wins++;
} else {
stats.losses++;
}
// Disable symbol if win rate < 40%
const total = stats.wins + stats.losses;
if (total >= 10) {
const winRate = stats.wins / total;
if (winRate < 0.4 && activeSymbols.has(symbol)) {
console.log(`Disabling ${symbol} due to low win rate: ${winRate}`);
activeSymbols.delete(symbol);
// Note: generator will continue until manually stopped
}
}
}
});
// Start all symbols
const cancellations = new Map();
for (const symbol of allSymbols) {
const cancel = await Live.background(symbol, {
strategyName: "my-strategy",
exchangeName: "binance"
});
cancellations.set(symbol, cancel);
}
// Monitor and disable underperforming symbols
setInterval(() => {
for (const [symbol, cancel] of cancellations.entries()) {
if (!activeSymbols.has(symbol)) {
cancel(); // Stop this symbol's execution
cancellations.delete(symbol);
console.log(`Stopped ${symbol}`);
}
}
}, 60000); // Check every minute
For backtesting across multiple symbols, the same parallel execution pattern applies:
import { Backtest } from "backtest-kit";
const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT"];
// Backtest all symbols in parallel
const results = await Promise.all(
symbols.map(async (symbol) => {
const symbolResults = [];
for await (const result of Backtest.run(symbol, {
strategyName: "my-strategy",
exchangeName: "binance",
frameName: "1d-backtest"
})) {
symbolResults.push({
symbol,
pnl: result.pnl.pnlPercentage,
reason: result.closeReason,
});
}
return { symbol, results: symbolResults };
})
);
// Aggregate results
results.forEach(({ symbol, results: symbolResults }) => {
const totalPnl = symbolResults.reduce((sum, r) => sum + r.pnl, 0);
const avgPnl = totalPnl / symbolResults.length;
console.log(`${symbol}: ${symbolResults.length} signals, avg PNL: ${avgPnl.toFixed(2)}%`);
});
// Generate combined report
await Backtest.dump("my-strategy");
| Symbol | Signals | Win Rate | Avg PNL | Best PNL | Worst PNL |
|---|---|---|---|---|---|
| BTCUSDT | 45 | 62.2% | +1.23% | +5.10% | -2.30% |
| ETHUSDT | 52 | 57.7% | +0.89% | +4.20% | -1.80% |
| SOLUSDT | 38 | 68.4% | +1.67% | +6.50% | -1.50% |
// Good: Limit concurrent symbols to avoid memory pressure
const MAX_CONCURRENT = 5;
const symbols = ["BTC", "ETH", "SOL", "BNB", "ADA", "DOT", "LINK", "MATIC"];
for (let i = 0; i < symbols.length; i += MAX_CONCURRENT) {
const batch = symbols.slice(i, i + MAX_CONCURRENT);
await Promise.all(batch.map(s => processSymbol(s)));
}
// Bad: Running 100 symbols in parallel without limits
await Promise.all(allSymbols.map(s => processSymbol(s)));
// Good: Wrap each symbol in try-catch
await Promise.all(
symbols.map(async (symbol) => {
try {
await processSymbol(symbol);
} catch (error) {
logger.error(`${symbol} failed:`, error);
// Continue with other symbols
}
})
);
// Bad: Single try-catch around all symbols
try {
await Promise.all(symbols.map(s => processSymbol(s)));
} catch (error) {
// One error crashes all symbols
}
// Good: Clean shutdown with cancellation
const cancellations = await Promise.all(
symbols.map(s => Live.background(s, context))
);
process.on("SIGINT", async () => {
console.log("Shutting down...");
cancellations.forEach(cancel => cancel());
await Live.dump("my-strategy"); // Save final report
process.exit(0);
});
// Bad: Abrupt exit without cleanup
process.on("SIGINT", () => process.exit(0));
// Good: Different strategies per symbol type
const btcConfig = { strategyName: "btc-strategy", exchangeName: "binance" };
const altConfig = { strategyName: "alt-strategy", exchangeName: "binance" };
await Promise.all([
Live.background("BTCUSDT", btcConfig),
Live.background("ETHUSDT", altConfig),
Live.background("SOLUSDT", altConfig),
]);
// Bad: Forcing same strategy on all symbols
await Promise.all(
symbols.map(s => Live.background(s, { strategyName: "one-size-fits-all", ... }))
);