- Added Jest + ts-jest configuration (jest.config.js) - Added global test setup with mocks (tests/setup.ts) - Added trade factory helpers (tests/helpers/trade-factory.ts) - Added 7 test suites covering Position Manager logic: - tp1-detection.test.ts (13 tests) - breakeven-sl.test.ts (9 tests) - adx-runner-sl.test.ts (18 tests) - trailing-stop.test.ts (14 tests) - edge-cases.test.ts (18 tests) - price-verification.test.ts (13 tests) - decision-helpers.test.ts (28 tests) - Added test documentation (tests/README.md) - Updated package.json with Jest dependencies and scripts - All 113 tests pass Co-authored-by: mindesbunister <32161838+mindesbunister@users.noreply.github.com>
248 lines
7.0 KiB
TypeScript
248 lines
7.0 KiB
TypeScript
/**
|
|
* Trade Factory - Test Helpers
|
|
*
|
|
* Factory functions to create mock trades for Position Manager testing.
|
|
* Uses realistic values based on actual trading data documented in:
|
|
* - Problem statement test data
|
|
* - Common Pitfalls documentation
|
|
*/
|
|
|
|
import { ActiveTrade } from '../../lib/trading/position-manager'
|
|
|
|
/**
|
|
* Default test values based on problem statement:
|
|
* - LONG: entry $140, TP1 $141.20 (+0.86%), TP2 $142.41 (+1.72%), SL $138.71 (-0.92%)
|
|
* - SHORT: entry $140, TP1 $138.80 (-0.86%), TP2 $137.59 (-1.72%), SL $141.29 (+0.92%)
|
|
* - ATR: 0.43, ADX: 26.9, Quality Score: 95, Position Size: $8000
|
|
*/
|
|
export const TEST_DEFAULTS = {
|
|
entry: 140.00,
|
|
atr: 0.43,
|
|
adx: 26.9,
|
|
qualityScore: 95,
|
|
positionSize: 8000,
|
|
leverage: 15,
|
|
|
|
// Calculated targets for LONG (entry + %)
|
|
long: {
|
|
tp1: 141.20, // +0.86%
|
|
tp2: 142.41, // +1.72%
|
|
sl: 138.71, // -0.92%
|
|
emergencySl: 137.20, // -2%
|
|
},
|
|
|
|
// Calculated targets for SHORT (entry - %)
|
|
short: {
|
|
tp1: 138.80, // -0.86%
|
|
tp2: 137.59, // -1.72%
|
|
sl: 141.29, // +0.92%
|
|
emergencySl: 142.80, // +2%
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Options for creating a mock trade
|
|
*/
|
|
export interface CreateMockTradeOptions {
|
|
id?: string
|
|
positionId?: string
|
|
symbol?: string
|
|
direction?: 'long' | 'short'
|
|
entryPrice?: number
|
|
positionSize?: number
|
|
leverage?: number
|
|
atr?: number
|
|
adx?: number
|
|
qualityScore?: number
|
|
|
|
// Targets
|
|
tp1Price?: number
|
|
tp2Price?: number
|
|
stopLossPrice?: number
|
|
emergencyStopPrice?: number
|
|
|
|
// State overrides
|
|
currentSize?: number
|
|
tp1Hit?: boolean
|
|
tp2Hit?: boolean
|
|
slMovedToBreakeven?: boolean
|
|
slMovedToProfit?: boolean
|
|
trailingStopActive?: boolean
|
|
peakPrice?: number
|
|
|
|
// P&L tracking
|
|
realizedPnL?: number
|
|
unrealizedPnL?: number
|
|
maxFavorableExcursion?: number
|
|
maxAdverseExcursion?: number
|
|
}
|
|
|
|
/**
|
|
* Generate a unique trade ID for testing
|
|
*/
|
|
let tradeCounter = 0
|
|
export function generateTradeId(): string {
|
|
return `test-trade-${++tradeCounter}-${Date.now()}`
|
|
}
|
|
|
|
/**
|
|
* Create a mock ActiveTrade object with sensible defaults
|
|
*/
|
|
export function createMockTrade(options: CreateMockTradeOptions = {}): ActiveTrade {
|
|
const direction = options.direction || 'long'
|
|
const entryPrice = options.entryPrice || TEST_DEFAULTS.entry
|
|
const positionSize = options.positionSize || TEST_DEFAULTS.positionSize
|
|
const targets = direction === 'long' ? TEST_DEFAULTS.long : TEST_DEFAULTS.short
|
|
|
|
return {
|
|
id: options.id || generateTradeId(),
|
|
positionId: options.positionId || `tx-${Date.now()}`,
|
|
symbol: options.symbol || 'SOL-PERP',
|
|
direction,
|
|
|
|
// Entry details
|
|
entryPrice,
|
|
entryTime: Date.now() - 60000, // Started 1 minute ago
|
|
positionSize,
|
|
leverage: options.leverage || TEST_DEFAULTS.leverage,
|
|
atrAtEntry: options.atr ?? TEST_DEFAULTS.atr,
|
|
adxAtEntry: options.adx ?? TEST_DEFAULTS.adx,
|
|
signalQualityScore: options.qualityScore ?? TEST_DEFAULTS.qualityScore,
|
|
signalSource: 'tradingview',
|
|
|
|
// Targets - use provided or calculate from direction
|
|
stopLossPrice: options.stopLossPrice ?? targets.sl,
|
|
tp1Price: options.tp1Price ?? targets.tp1,
|
|
tp2Price: options.tp2Price ?? targets.tp2,
|
|
emergencyStopPrice: options.emergencyStopPrice ?? targets.emergencySl,
|
|
|
|
// State
|
|
currentSize: options.currentSize ?? positionSize,
|
|
originalPositionSize: positionSize,
|
|
takeProfitPrice1: options.tp1Price ?? targets.tp1,
|
|
takeProfitPrice2: options.tp2Price ?? targets.tp2,
|
|
tp1Hit: options.tp1Hit ?? false,
|
|
tp2Hit: options.tp2Hit ?? false,
|
|
slMovedToBreakeven: options.slMovedToBreakeven ?? false,
|
|
slMovedToProfit: options.slMovedToProfit ?? false,
|
|
trailingStopActive: options.trailingStopActive ?? false,
|
|
|
|
// P&L tracking
|
|
realizedPnL: options.realizedPnL ?? 0,
|
|
unrealizedPnL: options.unrealizedPnL ?? 0,
|
|
peakPnL: 0,
|
|
peakPrice: options.peakPrice ?? entryPrice,
|
|
|
|
// MAE/MFE tracking (as percentages per Pitfall #54)
|
|
maxFavorableExcursion: options.maxFavorableExcursion ?? 0,
|
|
maxAdverseExcursion: options.maxAdverseExcursion ?? 0,
|
|
maxFavorablePrice: entryPrice,
|
|
maxAdversePrice: entryPrice,
|
|
|
|
// Monitoring
|
|
priceCheckCount: 0,
|
|
lastPrice: entryPrice,
|
|
lastUpdateTime: Date.now(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a LONG position with standard test defaults
|
|
*/
|
|
export function createLongTrade(options: Omit<CreateMockTradeOptions, 'direction'> = {}): ActiveTrade {
|
|
return createMockTrade({
|
|
...options,
|
|
direction: 'long',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Create a SHORT position with standard test defaults
|
|
*/
|
|
export function createShortTrade(options: Omit<CreateMockTradeOptions, 'direction'> = {}): ActiveTrade {
|
|
return createMockTrade({
|
|
...options,
|
|
direction: 'short',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Create a trade that has already hit TP1
|
|
*/
|
|
export function createTradeAfterTP1(
|
|
direction: 'long' | 'short',
|
|
options: Omit<CreateMockTradeOptions, 'direction' | 'tp1Hit'> = {}
|
|
): ActiveTrade {
|
|
const positionSize = options.positionSize || TEST_DEFAULTS.positionSize
|
|
const tp1SizePercent = 60 // Default TP1 closes 60%
|
|
const remainingSize = positionSize * ((100 - tp1SizePercent) / 100)
|
|
|
|
return createMockTrade({
|
|
...options,
|
|
direction,
|
|
tp1Hit: true,
|
|
slMovedToBreakeven: true,
|
|
currentSize: remainingSize,
|
|
// SL moves to entry (breakeven) after TP1 for weak ADX, or adjusted for strong ADX
|
|
stopLossPrice: options.entryPrice || TEST_DEFAULTS.entry,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Create a trade that has hit TP2 with trailing stop active
|
|
*/
|
|
export function createTradeAfterTP2(
|
|
direction: 'long' | 'short',
|
|
options: Omit<CreateMockTradeOptions, 'direction' | 'tp1Hit' | 'tp2Hit' | 'trailingStopActive'> = {}
|
|
): ActiveTrade {
|
|
const positionSize = options.positionSize || TEST_DEFAULTS.positionSize
|
|
const tp1SizePercent = 60
|
|
const tp2SizePercent = 0 // TP2 as runner - no close at TP2
|
|
const remainingSize = positionSize * ((100 - tp1SizePercent) / 100)
|
|
|
|
const entryPrice = options.entryPrice || TEST_DEFAULTS.entry
|
|
const targets = direction === 'long' ? TEST_DEFAULTS.long : TEST_DEFAULTS.short
|
|
|
|
return createMockTrade({
|
|
...options,
|
|
direction,
|
|
tp1Hit: true,
|
|
tp2Hit: true,
|
|
slMovedToBreakeven: true,
|
|
trailingStopActive: true,
|
|
currentSize: remainingSize,
|
|
peakPrice: targets.tp2, // Peak is at TP2 level
|
|
stopLossPrice: entryPrice, // Breakeven as starting point for trailing
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Helper to calculate expected profit percent
|
|
*/
|
|
export function calculateExpectedProfitPercent(
|
|
entryPrice: number,
|
|
currentPrice: number,
|
|
direction: 'long' | 'short'
|
|
): number {
|
|
if (direction === 'long') {
|
|
return ((currentPrice - entryPrice) / entryPrice) * 100
|
|
} else {
|
|
return ((entryPrice - currentPrice) / entryPrice) * 100
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to calculate expected target price
|
|
*/
|
|
export function calculateTargetPrice(
|
|
entryPrice: number,
|
|
percentChange: number,
|
|
direction: 'long' | 'short'
|
|
): number {
|
|
if (direction === 'long') {
|
|
return entryPrice * (1 + percentChange / 100)
|
|
} else {
|
|
return entryPrice * (1 - percentChange / 100)
|
|
}
|
|
}
|