/**
* Sample Strategy Implementations
*
* Simple strategies for testing the backtest framework.
* All strategies are pure functions: StrategyContext → StrategyDecision
*/
import {
StrategyContext,
StrategyDecision,
StrategyFn,
} from "./types";
import { calculateSMA } from "./backtest";
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Calculate target shares for a given allocation.
*/
function targetSharesForAllocation(
equity: number,
price: number,
allocation: number
): number {
if (price <= 0) return 0;
return Math.floor((equity * allocation) / price);
}
// ============================================
// STRATEGY: ALWAYS LONG (Baseline)
// ============================================
/**
* Always Long Strategy
*
* Simple baseline: always maintain maximum allowed position.
* Useful for comparing against buy-and-hold.
*/
export function createAlwaysLongStrategy(allocation: number = 0.95): StrategyFn {
return (context: StrategyContext): StrategyDecision => {
const targetShares = targetSharesForAllocation(
context.equity,
context.price,
allocation
);
return {
targetPositionShares: targetShares,
confidence: 1.0,
reason: "always long",
};
};
}
// ============================================
// STRATEGY: SMA CROSSOVER
// ============================================
export interface SMACrossoverConfig {
fastPeriod: number; // e.g., 20
slowPeriod: number; // e.g., 50
allocation: number; // e.g., 0.95
}
/**
* SMA Crossover Strategy
*
* - Long when fast SMA > slow SMA
* - Flat when fast SMA < slow SMA
*/
export function createSMACrossoverStrategy(config: SMACrossoverConfig): StrategyFn {
const { fastPeriod, slowPeriod, allocation } = config;
return (context: StrategyContext): StrategyDecision => {
const { analytics, priceHistory, equity, price } = context;
// Check if we have analytics with pre-computed SMAs
let fastSMA = analytics?.sma20;
let slowSMA = analytics?.sma50;
// If not using default periods or analytics missing, calculate from history
if (fastPeriod !== 20 || !fastSMA) {
fastSMA = priceHistory ? calculateSMA(priceHistory, fastPeriod) : undefined;
}
if (slowPeriod !== 50 || !slowSMA) {
slowSMA = priceHistory ? calculateSMA(priceHistory, slowPeriod) : undefined;
}
// Not enough data
if (fastSMA === undefined || slowSMA === undefined) {
return {
targetPositionShares: 0,
confidence: 0,
reason: `insufficient data for SMA(${fastPeriod}/${slowPeriod})`,
};
}
// Signal logic
const isBullish = fastSMA > slowSMA;
if (isBullish) {
const targetShares = targetSharesForAllocation(equity, price, allocation);
return {
targetPositionShares: targetShares,
confidence: 0.7,
reason: `SMA crossover bullish: SMA${fastPeriod}=${fastSMA.toFixed(2)} > SMA${slowPeriod}=${slowSMA.toFixed(2)}`,
};
} else {
return {
targetPositionShares: 0,
confidence: 0.7,
reason: `SMA crossover bearish: SMA${fastPeriod}=${fastSMA.toFixed(2)} < SMA${slowPeriod}=${slowSMA.toFixed(2)}`,
};
}
};
}
// ============================================
// STRATEGY: VOL REGIME
// ============================================
export interface VolRegimeConfig {
hvShortPeriod: number; // e.g., 20
hvLongPeriod: number; // e.g., 60
allocation: number; // e.g., 0.95
/** If true, go long when vol is compressing (HV short < HV long) */
longOnCompression: boolean;
}
/**
* Volatility Regime Strategy
*
* - If HV20 < HV60 (vol compressing) → risk-on (long)
* - If HV20 > HV60 (vol expanding) → risk-off (flat)
*/
export function createVolRegimeStrategy(config: VolRegimeConfig): StrategyFn {
const { hvShortPeriod, hvLongPeriod, allocation, longOnCompression } = config;
return (context: StrategyContext): StrategyDecision => {
const { analytics, equity, price } = context;
// Get volatility readings
const hvShort = analytics?.hv20;
const hvLong = analytics?.hv60;
// Not enough data
if (hvShort === undefined || hvLong === undefined) {
return {
targetPositionShares: 0,
confidence: 0,
reason: `insufficient data for HV(${hvShortPeriod}/${hvLongPeriod})`,
};
}
const isCompressing = hvShort < hvLong;
const shouldBeLong = longOnCompression ? isCompressing : !isCompressing;
if (shouldBeLong) {
const targetShares = targetSharesForAllocation(equity, price, allocation);
return {
targetPositionShares: targetShares,
confidence: 0.6,
reason: `vol regime: HV${hvShortPeriod}=${(hvShort * 100).toFixed(1)}% ${isCompressing ? "<" : ">"} HV${hvLongPeriod}=${(hvLong * 100).toFixed(1)}%`,
};
} else {
return {
targetPositionShares: 0,
confidence: 0.6,
reason: `vol regime risk-off: HV${hvShortPeriod}=${(hvShort * 100).toFixed(1)}% ${isCompressing ? "<" : ">"} HV${hvLongPeriod}=${(hvLong * 100).toFixed(1)}%`,
};
}
};
}
// ============================================
// STRATEGY: RSI MEAN REVERSION
// ============================================
export interface RSIMeanReversionConfig {
oversoldThreshold: number; // e.g., 30
overboughtThreshold: number; // e.g., 70
allocation: number; // e.g., 0.95
}
/**
* RSI Mean Reversion Strategy
*
* - Long when RSI < oversold threshold
* - Flat when RSI > overbought threshold
* - Hold current position otherwise
*/
export function createRSIMeanReversionStrategy(config: RSIMeanReversionConfig): StrategyFn {
const { oversoldThreshold, overboughtThreshold, allocation } = config;
return (context: StrategyContext): StrategyDecision => {
const { analytics, equity, price, position } = context;
const rsi = analytics?.rsi14;
// Not enough data
if (rsi === undefined) {
return {
targetPositionShares: 0,
confidence: 0,
reason: "insufficient data for RSI",
};
}
if (rsi < oversoldThreshold) {
const targetShares = targetSharesForAllocation(equity, price, allocation);
return {
targetPositionShares: targetShares,
confidence: 0.7,
reason: `RSI oversold: ${rsi.toFixed(1)} < ${oversoldThreshold}`,
};
} else if (rsi > overboughtThreshold) {
return {
targetPositionShares: 0,
confidence: 0.7,
reason: `RSI overbought: ${rsi.toFixed(1)} > ${overboughtThreshold}`,
};
} else {
// Hold current
return {
targetPositionShares: position,
confidence: 0.4,
reason: `RSI neutral: ${rsi.toFixed(1)}`,
};
}
};
}
// ============================================
// STRATEGY: TREND + VOL FILTER
// ============================================
export interface TrendVolFilterConfig {
smaPeriod: number; // e.g., 50
maxVolThreshold: number; // e.g., 0.30 (30% annualized)
allocation: number; // e.g., 0.95
}
/**
* Trend + Vol Filter Strategy
*
* - Long when price > SMA AND volatility is not extreme
* - Flat otherwise
*/
export function createTrendVolFilterStrategy(config: TrendVolFilterConfig): StrategyFn {
const { smaPeriod, maxVolThreshold, allocation } = config;
return (context: StrategyContext): StrategyDecision => {
const { analytics, price, equity, priceHistory } = context;
const hv = analytics?.hv20;
let sma = analytics?.sma50;
if (smaPeriod !== 50) {
sma = priceHistory ? calculateSMA(priceHistory, smaPeriod) : undefined;
}
// Not enough data
if (sma === undefined || hv === undefined) {
return {
targetPositionShares: 0,
confidence: 0,
reason: "insufficient data for trend+vol filter",
};
}
const isUptrend = price > sma;
const isVolOK = hv < maxVolThreshold;
if (isUptrend && isVolOK) {
const targetShares = targetSharesForAllocation(equity, price, allocation);
return {
targetPositionShares: targetShares,
confidence: 0.75,
reason: `trend up (${price.toFixed(2)} > SMA${smaPeriod}=${sma.toFixed(2)}) + vol OK (${(hv * 100).toFixed(1)}%)`,
};
} else if (!isUptrend) {
return {
targetPositionShares: 0,
confidence: 0.7,
reason: `trend down: ${price.toFixed(2)} < SMA${smaPeriod}=${sma.toFixed(2)}`,
};
} else {
return {
targetPositionShares: 0,
confidence: 0.65,
reason: `vol too high: ${(hv * 100).toFixed(1)}% > ${(maxVolThreshold * 100).toFixed(1)}%`,
};
}
};
}
// ============================================
// STRATEGY: DUAL MOMENTUM
// ============================================
export interface DualMomentumConfig {
lookbackPeriod: number; // e.g., 60 days
allocation: number; // e.g., 0.95
}
/**
* Dual Momentum Strategy
*
* - Long if price is higher than lookbackPeriod ago AND positive momentum
* - Flat otherwise
*/
export function createDualMomentumStrategy(config: DualMomentumConfig): StrategyFn {
const { lookbackPeriod, allocation } = config;
return (context: StrategyContext): StrategyDecision => {
const { priceHistory, price, equity } = context;
if (!priceHistory || priceHistory.length < lookbackPeriod) {
return {
targetPositionShares: 0,
confidence: 0,
reason: `insufficient data for ${lookbackPeriod}-day momentum`,
};
}
const priceNDaysAgo = priceHistory[lookbackPeriod - 1];
const momentum = (price - priceNDaysAgo) / priceNDaysAgo;
if (momentum > 0) {
const targetShares = targetSharesForAllocation(equity, price, allocation);
return {
targetPositionShares: targetShares,
confidence: Math.min(0.9, 0.5 + momentum),
reason: `positive ${lookbackPeriod}-day momentum: ${(momentum * 100).toFixed(1)}%`,
};
} else {
return {
targetPositionShares: 0,
confidence: 0.6,
reason: `negative ${lookbackPeriod}-day momentum: ${(momentum * 100).toFixed(1)}%`,
};
}
};
}
// ============================================
// STRATEGY FACTORY
// ============================================
export type StrategyType =
| "always_long"
| "sma_crossover"
| "vol_regime"
| "rsi_mean_reversion"
| "trend_vol_filter"
| "dual_momentum";
/**
* Create a strategy by name with default configuration.
*/
export function createStrategy(type: StrategyType): StrategyFn {
switch (type) {
case "always_long":
return createAlwaysLongStrategy(0.95);
case "sma_crossover":
return createSMACrossoverStrategy({ fastPeriod: 20, slowPeriod: 50, allocation: 0.95 });
case "vol_regime":
return createVolRegimeStrategy({ hvShortPeriod: 20, hvLongPeriod: 60, allocation: 0.95, longOnCompression: true });
case "rsi_mean_reversion":
return createRSIMeanReversionStrategy({ oversoldThreshold: 30, overboughtThreshold: 70, allocation: 0.95 });
case "trend_vol_filter":
return createTrendVolFilterStrategy({ smaPeriod: 50, maxVolThreshold: 0.30, allocation: 0.95 });
case "dual_momentum":
return createDualMomentumStrategy({ lookbackPeriod: 60, allocation: 0.95 });
default:
throw new Error(`Unknown strategy type: ${type}`);
}
}