Markdown Services comprise the reporting and analytics layer of the backtest-kit framework. These services listen to various event emitters, accumulate signal and performance data, calculate trading statistics, and generate formatted markdown reports persisted to disk. Each service specializes in a specific aspect of trading analysis: backtest results, live trading events, scheduled signals, partial profit/loss milestones, strategy comparisons, portfolio heatmaps, and performance profiling.
For information about the event system that feeds these services, see 3.4. For details about the execution modes that generate events, see 2.1.
All Markdown Services follow a consistent architecture with three layers: Event Subscription, Storage Accumulation, and Report Generation. Each service subscribes to specific event emitters during initialization, maintains memoized storage instances per symbol-strategy pair, and provides methods to retrieve statistics or generate markdown reports.
| Service | Event Source | Scope | Report Type | Statistics Interface |
|---|---|---|---|---|
BacktestMarkdownService |
signalBacktestEmitter |
Symbol-Strategy | Closed signals only | BacktestStatistics |
LiveMarkdownService |
signalLiveEmitter |
Symbol-Strategy | All events (idle/opened/active/closed) | LiveStatistics |
ScheduleMarkdownService |
signalEmitter |
Symbol-Strategy | Scheduled/opened/cancelled signals | ScheduleStatistics |
PartialMarkdownService |
partialProfitSubject, partialLossSubject |
Symbol-Strategy | Profit/loss milestones | PartialStatistics |
WalkerMarkdownService |
walkerEmitter |
Walker | Strategy comparison | WalkerStatistics |
HeatMarkdownService |
signalEmitter |
Strategy | Portfolio heatmap | IHeatmapStatistics |
PerformanceMarkdownService |
performanceEmitter |
Symbol-Strategy | Performance profiling | PerformanceStatistics |
All Markdown Services implement a consistent internal structure with a memoized ReportStorage class that handles data accumulation and report generation. The service layer provides dependency injection integration and event subscription.
Each service uses memoize from functools-kit to create and cache ReportStorage instances. The key is typically ${symbol}:${strategyName} for symbol-strategy scoped services, or just ${walkerName} for walker services.
// Example from BacktestMarkdownService
private getStorage = memoize<(symbol: string, strategyName: string) => ReportStorage>(
([symbol, strategyName]) => `${symbol}:${strategyName}`,
() => new ReportStorage()
);
This ensures that each unique symbol-strategy pair maintains isolated state, preventing cross-contamination of statistics.
Services use singleshot from functools-kit to ensure event subscription occurs exactly once. This initialization happens lazily on first use.
protected init = singleshot(async () => {
this.loggerService.log("backtestMarkdownService init");
signalBacktestEmitter.subscribe(this.tick);
});
Accumulates closed signals from backtest executions and generates comprehensive performance reports. Only processes signals with action === "closed", ignoring opened/active/idle states.
signalBacktestEmitterMAX_EVENTS (250) closed signals per symbol-strategy pairinterface BacktestStatistics {
signalList: IStrategyTickResultClosed[];
totalSignals: number;
winCount: number;
lossCount: number;
winRate: number | null; // 0-100%, null if unsafe
avgPnl: number | null; // percentage
totalPnl: number | null; // cumulative percentage
stdDev: number | null; // volatility
sharpeRatio: number | null; // avgPnl / stdDev
annualizedSharpeRatio: number | null; // sharpeRatio × √365
certaintyRatio: number | null; // avgWin / |avgLoss|
expectedYearlyReturns: number | null; // avgPnl × tradesPerYear
}
Tracks all tick events from live trading including idle, opened, active, and closed states. Provides real-time monitoring of strategy execution with automatic idle event deduplication.
signalLiveEmitterMAX_EVENTS (250) events per symbol-strategy pairsignalId to avoid duplicate active rowsThe service implements smart idle event deduplication to prevent idle event spam while preserving important state transitions:
Active events for the same signal are replaced rather than accumulated, ensuring only the latest active state is stored:
// Find the last active event with the same signalId
const lastActiveIndex = this._eventList.findLastIndex(
(event) => event.action === "active" && event.signalId === data.signal.id
);
// Replace the last active event with the same signalId
if (lastActiveIndex !== -1) {
this._eventList[lastActiveIndex] = newEvent;
return;
}
Monitors scheduled signals (limit orders) through their lifecycle: scheduled → opened or cancelled. Tracks activation times and cancellation rates.
signalEmitter (global, not live-specific)scheduled, opened, and cancelled actionsscheduledAt !== pendingAt (was previously scheduled)interface ScheduleStatistics {
eventList: ScheduledEvent[];
totalEvents: number;
totalScheduled: number;
totalOpened: number;
totalCancelled: number;
cancellationRate: number | null; // (cancelled / scheduled) × 100
activationRate: number | null; // (opened / scheduled) × 100
avgWaitTime: number | null; // minutes for cancelled signals
avgActivationTime: number | null; // minutes for opened signals
}
Tracks partial profit and loss milestones (10%, 20%, 30%, etc.) as signals progress toward TP or SL. Provides insight into price action before final close.
partialProfitSubject and partialLossSubjectinterface PartialStatistics {
eventList: PartialEvent[];
totalEvents: number;
totalProfit: number; // count of profit milestone events
totalLoss: number; // count of loss milestone events
}
Aggregates results from walker strategy comparisons. Ranks strategies by optimization metric and generates comparison tables with PNL details for all closed signals across all strategies.
walkerEmitterReportStorage instance per walker name (not per symbol-strategy)totalStrategies, bestStats, bestMetric, bestStrategyinterface WalkerStatistics extends IWalkerResults {
strategyResults: IStrategyResult[]; // Array of all strategy results
}
interface IStrategyResult {
strategyName: StrategyName;
stats: BacktestStatistics;
metricValue: number | null; // Value of optimization metric
}
The service dynamically creates column configuration based on the optimization metric:
Provides portfolio-level analytics by aggregating signals across all symbols for a strategy. Calculates per-symbol statistics and portfolio-wide metrics.
signalEmitter (global)strategyName only (not symbol)Map<string, IStrategyTickResultClosed[]> for symbol-level storageinterface IHeatmapStatistics {
symbols: IHeatmapRow[]; // Per-symbol stats
totalSymbols: number;
portfolioTotalPnl: number | null;
portfolioSharpeRatio: number | null; // Weighted by trade count
portfolioTotalTrades: number;
}
interface IHeatmapRow {
symbol: string;
totalPnl: number | null;
sharpeRatio: number | null;
maxDrawdown: number | null;
profitFactor: number | null; // sumWins / |sumLosses|
expectancy: number | null; // winRate × avgWin + lossRate × avgLoss
winRate: number | null;
avgWin: number | null;
avgLoss: number | null;
maxWinStreak: number;
maxLossStreak: number;
totalTrades: number;
// ... additional fields
}
Profiles execution performance by tracking metric durations, percentiles, and wait times. Identifies bottlenecks in strategy execution.
performanceEmittermetricType (e.g., "getSignal", "getCandles", "checkSignal")MAX_EVENTS (10,000) per symbol-strategy pairinterface PerformanceStatistics {
strategyName: string;
totalEvents: number;
totalDuration: number; // milliseconds
metricStats: Record<string, MetricStats>;
events: PerformanceContract[];
}
interface MetricStats {
metricType: PerformanceMetricType;
count: number;
totalDuration: number;
avgDuration: number;
minDuration: number;
maxDuration: number;
stdDev: number;
median: number;
p95: number; // 95th percentile
p99: number; // 99th percentile
avgWaitTime: number; // interval between consecutive events
minWaitTime: number;
maxWaitTime: number;
}
The service calculates percentiles to identify outliers and tail latencies:
function percentile(sortedArray: number[], p: number): number {
if (sortedArray.length === 0) return 0;
const index = Math.ceil((sortedArray.length * p) / 100) - 1;
return sortedArray[Math.max(0, index)];
}
// Usage
median: percentile(durations, 50),
p95: percentile(durations, 95),
p99: percentile(durations, 99),
All services use a Column[] array to define table structure. Each column specifies how to extract and format data from event objects.
interface Column {
key: string; // Unique identifier
label: string; // Table header
format: (data: EventType) => string; // Data formatter
isVisible: () => boolean; // Conditional rendering
}
const columns: Column[] = [
{
key: "signalId",
label: "Signal ID",
format: (data) => data.signal.id,
isVisible: () => true,
},
{
key: "note",
label: "Note",
format: (data) => toPlainString(data.signal.note ?? "N/A"),
isVisible: () => GLOBAL_CONFIG.CC_REPORT_SHOW_SIGNAL_NOTE,
},
{
key: "pnl",
label: "PNL (net)",
format: (data) => {
const pnlPercentage = data.pnl.pnlPercentage;
return `${pnlPercentage > 0 ? "+" : ""}${pnlPercentage.toFixed(2)}%`;
},
isVisible: () => true,
},
// ... more columns
];
The generated markdown follows this format:
| Signal ID | Symbol | Position | PNL (net) | Close Reason | Duration (min) |
| --- | --- | --- | --- | --- | --- |
| abc123 | BTCUSDT | LONG | +2.45% | take_profit | 45 |
| def456 | ETHUSDT | SHORT | -1.20% | stop_loss | 30 |
All services implement isUnsafe() function to detect NaN, Infinity, or null values. Unsafe values are replaced with null in statistics interfaces, ensuring reports display "N/A" instead of corrupted numbers.
function isUnsafe(value: number | null): boolean {
if (typeof value !== "number") {
return true;
}
if (isNaN(value)) {
return true;
}
if (!isFinite(value)) {
return true;
}
return false;
}
// Usage
if (isUnsafe(winRate)) winRate = null;
if (isUnsafe(sharpeRatio)) sharpeRatio = null;
This prevents division by zero, empty array reductions, and other edge cases from producing invalid statistics.
All services expose four public methods for programmatic access:
| Method | Parameters | Returns | Purpose |
|---|---|---|---|
getData() |
symbol, strategyName |
Promise<Statistics> |
Retrieve calculated statistics |
getReport() |
symbol, strategyName |
Promise<string> |
Generate markdown report |
dump() |
symbol, strategyName, path? |
Promise<void> |
Save report to disk |
clear() |
ctx? |
Promise<void> |
Clear accumulated data |
// Get statistics programmatically
const stats = await Backtest.getData("BTCUSDT", "my-strategy");
console.log(`Win Rate: ${stats.winRate}%`);
console.log(`Sharpe Ratio: ${stats.sharpeRatio}`);
// Generate markdown string
const markdown = await Backtest.getReport("BTCUSDT", "my-strategy");
console.log(markdown);
// Save to disk
await Backtest.dump("BTCUSDT", "my-strategy", "./custom/path");
// Clear data
await Backtest.clear({ symbol: "BTCUSDT", strategyName: "my-strategy" });
Reports are written to ./dump/ directory with subdirectories per service type:
./dump/
├── backtest/
│ ├── strategy-a.md
│ └── strategy-b.md
├── live/
│ ├── strategy-a.md
│ └── strategy-b.md
├── schedule/
│ ├── strategy-a.md
│ └── strategy-b.md
├── partial/
│ ├── BTCUSDT_strategy-a.md
│ └── ETHUSDT_strategy-a.md
├── walker/
│ ├── walker-comparison-1.md
│ └── walker-comparison-2.md
├── heatmap/
│ ├── strategy-a.md
│ └── strategy-b.md
└── performance/
├── BTCUSDT_strategy-a.md
└── ETHUSDT_strategy-a.md
Naming conventions:
{strategyName}.md{symbol}_{strategyName}.md{walkerName}.mdMarkdown Services are automatically initialized and subscribed to their respective emitters. The initialization occurs lazily via singleshot wrapper, ensuring subscriptions happen only once regardless of how many times the service is accessed.