/**
* Strategy Module Tests
*
* Tests for risk management and backtest execution.
*/
import { describe, it, expect } from "vitest";
import {
// Risk management
createRiskState,
updateRiskState,
calculateDrawdown,
calculateDailyLoss,
applyRiskManagement,
createRiskLimits,
// Backtest
runBacktest,
calculateSMA,
calculateRSI,
calculateHistoricalVol,
// Strategies
createAlwaysLongStrategy,
createSMACrossoverStrategy,
// Types
StrategyContext,
StrategyDecision,
RiskState,
RiskLimits,
PriceBar,
StrategyStrategyBacktestConfig,
} from "./index";
// ============================================
// RISK MANAGEMENT TESTS
// ============================================
describe("Risk Management", () => {
describe("calculateDrawdown", () => {
it("returns 0 when equity equals peak", () => {
expect(calculateDrawdown(10000, 10000)).toBe(0);
});
it("calculates correct drawdown percentage", () => {
// 10% drawdown
expect(calculateDrawdown(9000, 10000)).toBeCloseTo(0.1, 4);
// 25% drawdown
expect(calculateDrawdown(7500, 10000)).toBeCloseTo(0.25, 4);
});
it("handles edge case of zero peak", () => {
expect(calculateDrawdown(100, 0)).toBe(0);
});
});
describe("calculateDailyLoss", () => {
it("returns 0 when PnL is positive", () => {
const state: RiskState = {
peakEquity: 10000,
dailyPnl: 500,
dayStartEquity: 10000,
};
expect(calculateDailyLoss(state)).toBe(0);
});
it("calculates correct daily loss percentage", () => {
const state: RiskState = {
peakEquity: 10000,
dailyPnl: -500,
dayStartEquity: 10000,
};
expect(calculateDailyLoss(state)).toBeCloseTo(0.05, 4);
});
});
describe("applyRiskManagement", () => {
const baseContext: StrategyContext = {
date: new Date("2024-01-15"),
symbol: "SPY",
price: 100,
equity: 10000,
position: 0,
};
const baseLimits: RiskLimits = createRiskLimits({
maxNotionalPerSymbol: 0.25,
maxPortfolioDrawdown: 0.20,
maxDailyLoss: 0.05,
});
it("passes through decision when within limits", () => {
const decision: StrategyDecision = {
targetPositionShares: 20,
confidence: 0.8,
reason: "test signal",
};
const state: RiskState = {
peakEquity: 10000,
dailyPnl: 0,
dayStartEquity: 10000,
};
const result = applyRiskManagement(decision, baseContext, baseLimits, state);
expect(result.targetPositionShares).toBe(20);
expect(result.wasAdjusted).toBe(false);
});
it("forces flat when drawdown exceeds limit", () => {
const decision: StrategyDecision = {
targetPositionShares: 50,
confidence: 0.8,
reason: "test signal",
};
// 25% drawdown (equity 7500 from peak 10000)
const state: RiskState = {
peakEquity: 10000,
dailyPnl: 0,
dayStartEquity: 7500,
};
const context = { ...baseContext, equity: 7500 };
const result = applyRiskManagement(decision, context, baseLimits, state);
expect(result.targetPositionShares).toBe(0);
expect(result.wasAdjusted).toBe(true);
expect(result.reason).toContain("max drawdown exceeded");
});
it("forces flat when daily loss exceeds limit", () => {
const decision: StrategyDecision = {
targetPositionShares: 50,
confidence: 0.8,
reason: "test signal",
};
// 7% daily loss
const state: RiskState = {
peakEquity: 10000,
dailyPnl: -700,
dayStartEquity: 10000,
};
const context = { ...baseContext, equity: 9300 };
const result = applyRiskManagement(decision, context, baseLimits, state);
expect(result.targetPositionShares).toBe(0);
expect(result.wasAdjusted).toBe(true);
expect(result.reason).toContain("daily loss limit");
});
it("caps position by notional limit", () => {
const decision: StrategyDecision = {
targetPositionShares: 100, // Would be $10,000 notional
confidence: 0.8,
reason: "test signal",
};
const state: RiskState = {
peakEquity: 10000,
dailyPnl: 0,
dayStartEquity: 10000,
};
// Max notional = 25% of $10,000 = $2,500 = 25 shares at $100
const result = applyRiskManagement(decision, baseContext, baseLimits, state);
expect(result.targetPositionShares).toBe(25);
expect(result.wasAdjusted).toBe(true);
expect(result.riskAnnotations.some(a => a.includes("notional limit"))).toBe(true);
});
});
describe("updateRiskState", () => {
it("updates peak equity when new high", () => {
const state = createRiskState(10000);
const newState = updateRiskState(state, 11000, new Date("2024-01-15"));
expect(newState.peakEquity).toBe(11000);
});
it("maintains peak equity when below", () => {
let state = createRiskState(10000);
state = updateRiskState(state, 11000, new Date("2024-01-15"));
state = updateRiskState(state, 10500, new Date("2024-01-15"));
expect(state.peakEquity).toBe(11000);
});
it("resets daily PnL on new day", () => {
let state = createRiskState(10000);
state = updateRiskState(state, 10500, new Date("2024-01-15"));
state.dailyPnl = 500;
// New day
state = updateRiskState(state, 10500, new Date("2024-01-16"));
expect(state.dailyPnl).toBe(0);
expect(state.dayStartEquity).toBe(10500);
});
});
});
// ============================================
// TECHNICAL INDICATOR TESTS
// ============================================
describe("Technical Indicators", () => {
describe("calculateSMA", () => {
it("calculates correct SMA", () => {
const prices = [100, 98, 102, 97, 103]; // newest first
const sma = calculateSMA(prices, 5);
expect(sma).toBeCloseTo(100, 2);
});
it("returns undefined with insufficient data", () => {
const prices = [100, 98, 102];
expect(calculateSMA(prices, 5)).toBeUndefined();
});
});
describe("calculateRSI", () => {
it("returns 100 when all gains", () => {
// Steadily rising prices (newest first)
const prices = [110, 108, 106, 104, 102, 100, 98, 96, 94, 92, 90, 88, 86, 84, 82, 80];
const rsi = calculateRSI(prices, 14);
expect(rsi).toBe(100);
});
it("returns reasonable RSI for mixed prices", () => {
// Mixed prices
const prices = [100, 102, 99, 101, 98, 100, 97, 99, 96, 98, 95, 97, 94, 96, 93, 95];
const rsi = calculateRSI(prices, 14);
expect(rsi).toBeGreaterThan(0);
expect(rsi).toBeLessThan(100);
});
});
describe("calculateHistoricalVol", () => {
it("returns reasonable volatility for daily prices", () => {
// Generate some realistic price data
const prices: number[] = [];
let price = 100;
for (let i = 0; i < 30; i++) {
prices.push(price);
price *= (1 + (Math.random() - 0.5) * 0.02); // ~1% daily moves
}
const vol = calculateHistoricalVol(prices, 20);
expect(vol).toBeGreaterThan(0);
expect(vol).toBeLessThan(1); // Should be annualized, typical < 100%
});
it("returns undefined with insufficient data", () => {
const prices = [100, 99, 101];
expect(calculateHistoricalVol(prices, 20)).toBeUndefined();
});
});
});
// ============================================
// BACKTEST TESTS
// ============================================
describe("Backtest Execution", () => {
/**
* Generate synthetic price bars.
*/
function generatePriceBars(
startPrice: number,
days: number,
dailyReturn: number = 0.001, // 0.1% daily
noise: number = 0.005 // 0.5% daily noise
): PriceBar[] {
const bars: PriceBar[] = [];
let price = startPrice;
const startDate = new Date("2023-01-01");
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
// Skip weekends
if (date.getDay() === 0 || date.getDay() === 6) continue;
const dailyMove = dailyReturn + (Math.random() - 0.5) * noise * 2;
const open = price;
price *= (1 + dailyMove);
const close = price;
const high = Math.max(open, close) * (1 + Math.random() * 0.005);
const low = Math.min(open, close) * (1 - Math.random() * 0.005);
bars.push({ date, open, high, low, close });
}
return bars;
}
describe("runBacktest with Always Long strategy", () => {
it("increases equity on rising prices", () => {
const bars = generatePriceBars(100, 300, 0.0005, 0.002); // Steadily rising
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
slippageBps: 0,
commissionPerShare: 0,
};
const strategy = createAlwaysLongStrategy(0.9);
const result = runBacktest("Always Long", strategy, bars, config);
expect(result.equityCurve.length).toBe(bars.length);
expect(result.summary.endingEquity).toBeGreaterThan(config.initialEquity);
expect(result.metrics.totalReturn).toBeGreaterThan(0);
});
it("records trades correctly", () => {
const bars = generatePriceBars(100, 100, 0.001, 0.001);
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
};
const strategy = createAlwaysLongStrategy(0.9);
const result = runBacktest("Always Long", strategy, bars, config);
// Should have at least one trade (initial position)
expect(result.trades.length).toBeGreaterThan(0);
// First trade should be a buy
const firstTrade = result.trades[0];
expect(firstTrade.positionBefore).toBe(0);
expect(firstTrade.positionAfter).toBeGreaterThan(0);
expect(firstTrade.sharesDelta).toBeGreaterThan(0);
});
it("computes risk metrics", () => {
const bars = generatePriceBars(100, 500, 0.0003, 0.01);
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
};
const strategy = createAlwaysLongStrategy(0.9);
const result = runBacktest("Always Long", strategy, bars, config);
expect(result.metrics.sharpeRatio).toBeDefined();
expect(result.metrics.maxDrawdown).toBeGreaterThanOrEqual(0);
expect(result.metrics.annualizedVolatility).toBeGreaterThan(0);
});
});
describe("runBacktest with SMA Crossover strategy", () => {
it("stays flat when insufficient data for SMA", () => {
const bars = generatePriceBars(100, 30, 0.001, 0.005); // Only 30 bars
const config: StrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
lookbackBars: 200,
};
const strategy = createSMACrossoverStrategy({
fastPeriod: 50,
slowPeriod: 100, // Requires 100 bars - more than we have
allocation: 0.9,
});
const result = runBacktest("SMA Crossover", strategy, bars, config);
// With only 30 bars, no position should be taken since we can't compute 100-bar SMA
expect(result.summary.endingEquity).toBe(config.initialEquity);
expect(result.trades.length).toBe(0);
});
it("generates signals after warmup period", () => {
const bars = generatePriceBars(100, 200, 0.001, 0.005);
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
};
const strategy = createSMACrossoverStrategy({
fastPeriod: 10,
slowPeriod: 20,
allocation: 0.9,
});
const result = runBacktest("SMA Crossover", strategy, bars, config);
// Should have some trades with SMA crossover reasons
const smaTradeCount = result.trades.filter(t =>
t.reason.includes("SMA") || t.reason.includes("crossover")
).length;
expect(smaTradeCount).toBeGreaterThan(0);
});
});
describe("Risk management integration", () => {
it("caps position size by notional limit", () => {
const bars = generatePriceBars(100, 100, 0.001, 0.001);
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits({
maxNotionalPerSymbol: 0.1, // Only 10% allocation
}),
};
const strategy = createAlwaysLongStrategy(0.9); // Wants 90%
const result = runBacktest("Always Long", strategy, bars, config);
// All trades should be capped to ~10% notional
for (const trade of result.trades) {
const notionalPercent = trade.notional / config.initialEquity;
// Allow some slippage but should be close to limit
expect(notionalPercent).toBeLessThanOrEqual(0.15);
}
});
it("forces flat when drawdown limit exceeded", () => {
// Create a price series that crashes
const bars: PriceBar[] = [];
let price = 100;
const startDate = new Date("2023-01-01");
for (let i = 0; i < 100; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
if (date.getDay() === 0 || date.getDay() === 6) continue;
// Crash after day 30
const dailyMove = i < 30 ? 0.001 : -0.02;
const open = price;
price *= (1 + dailyMove);
const close = price;
const high = Math.max(open, close);
const low = Math.min(open, close);
bars.push({ date, open, high, low, close });
}
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits({
maxPortfolioDrawdown: 0.15, // 15% max drawdown
}),
};
const strategy = createAlwaysLongStrategy(0.9);
const result = runBacktest("Always Long", strategy, bars, config);
// Should have risk interventions
expect(result.summary.riskInterventions).toBeGreaterThan(0);
// Should have trades that mention drawdown
const drawdownTrades = result.trades.filter(t =>
t.reason.includes("drawdown")
);
expect(drawdownTrades.length).toBeGreaterThan(0);
});
});
describe("Edge cases", () => {
it("throws with insufficient bars", () => {
const bars = generatePriceBars(100, 1, 0, 0);
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
};
const strategy = createAlwaysLongStrategy(0.9);
expect(() => runBacktest("Always Long", strategy, bars, config))
.toThrow("Need at least 2 bars");
});
it("handles zero price gracefully", () => {
const bars: PriceBar[] = [
{ date: new Date("2023-01-01"), open: 100, high: 101, low: 99, close: 100 },
{ date: new Date("2023-01-02"), open: 100, high: 101, low: 99, close: 0 },
];
const config: StrategyStrategyBacktestConfig = {
symbol: "TEST",
initialEquity: 10000,
riskLimits: createRiskLimits(),
};
const strategy = createAlwaysLongStrategy(0.9);
// Should not throw
const result = runBacktest("Always Long", strategy, bars, config);
expect(result).toBeDefined();
});
});
});