Skip to main content
Glama
Ademscodeisnotsobad

Quant Companion MCP

strategy.test.ts15.7 kB
/** * 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(); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ademscodeisnotsobad/Quant-Companion-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server