Merge remote-tracking branch 'github/master'

This commit is contained in:
mindesbunister
2025-12-05 13:49:00 +01:00
15 changed files with 5836 additions and 64 deletions

View File

@@ -34,6 +34,148 @@
---
## 🔍 "DO I ALREADY HAVE THIS?" - Quick Feature Discovery
**Before implementing ANY feature, check if it already exists!** This system has 70+ features built over months of development.
### Quick Reference Table
| "I want to..." | Existing Feature | Search Term |
|----------------|------------------|-------------|
| Re-enter after stop-out | **Stop Hunt Revenge System** - Auto re-enters quality 85+ signals after price reverses through original entry | `grep -i "stop hunt revenge"` |
| Scale position by quality | **Adaptive Leverage System** - 10x for quality 95+, 5x for borderline signals | `grep -i "adaptive leverage"` |
| Test different timeframes | **Multi-Timeframe Data Collection** - Parallel data collection for 5min/15min/1H/4H/Daily | `grep -i "multi-timeframe"` |
| Monitor blocked signals | **BlockedSignal Tracker** - Tracks quality-blocked signals with price analysis | `grep -i "blockedsignal"` |
| Survive server failures | **HA Failover** - Secondary server with auto DNS failover (90s detection) | `grep -i "high availability"` |
| Validate re-entries | **Re-Entry Analytics System** - Fresh TradingView data + recent performance scoring | `grep -i "re-entry analytics"` |
| Backtest parameters | **Distributed Cluster Backtester** - 65,536 combo sweep on EPYC cluster | `grep -i "cluster\|backtester"` |
| Handle RPC rate limits | **Retry with Exponential Backoff** - 5s → 10s → 20s retry for 429 errors | `grep -i "retryWithBackoff"` |
| Track best/worst P&L | **MAE/MFE Tracking** - Built into Position Manager, updated every 2s | `grep -i "mae\|mfe"` |
### Quick Search Commands
```bash
# Search main documentation
grep -i "KEYWORD" .github/copilot-instructions.md
# Search all documentation
grep -ri "KEYWORD" docs/
# Check live system logs
docker logs trading-bot-v4 | grep -i "KEYWORD" | tail -20
# List database tables (shows what data is tracked)
docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt"
# Check environment variables
cat .env | grep -i "KEYWORD"
# Search codebase
grep -r "KEYWORD" lib/ app/ --include="*.ts"
```
### Feature Discovery by Category
**📊 Entry/Exit Logic:**
- ATR-based TP/SL (dynamic targets based on volatility)
- TP2-as-runner (40% runner after TP1, configurable)
- ADX-based runner SL (adaptive positioning by trend strength)
- Adaptive trailing stop (real-time 1-min ADX adjustments)
- Emergency stop (-2% hard limit)
**🛡️ Risk Management:**
- Adaptive leverage (quality-based position sizing)
- Direction-specific thresholds (LONG 90+, SHORT 80+)
- Per-symbol sizing (SOL/ETH independent controls)
- Phantom trade auto-closure (size mismatch detection)
- Dual stops (soft TRIGGER_LIMIT + hard TRIGGER_MARKET)
**🔄 Re-Entry & Recovery:**
- Stop Hunt Revenge (auto re-entry after reversal)
- Re-Entry Analytics (validation with fresh data)
- Market Data Cache (5-min expiry TradingView data)
**📈 Monitoring & Analysis:**
- Position Manager (2s price checks, MAE/MFE tracking)
- BlockedSignal Tracker (quality-blocked signal analysis)
- Multi-timeframe collection (parallel data gathering)
- Rate limit monitoring (429 error tracking + analytics)
- Drift health monitor (memory leak detection + auto-restart)
**🏗️ High Availability:**
- Secondary server (Hostinger standby)
- Database replication (PostgreSQL streaming)
- DNS auto-failover (90s detection via INWX API)
- Orphan position recovery (startup validation)
**🔧 Developer Tools:**
- Distributed cluster (EPYC parameter sweep)
- Test suite (113 tests, 7 test files)
- CI/CD pipeline (GitHub Actions)
- Persistent logger (survives container restarts)
### Decision Flowchart: Does This Feature Exist?
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Search copilot-instructions.md │
│ grep -i "feature-name" .github/copilot-instructions.md │
│ │ │
│ ▼ │
│ Found? ──YES──► READ THE SECTION │
│ │ │
│ NO │
│ ▼ │
│ 2. Search docs/ directory │
│ grep -ri "feature-name" docs/ │
│ │ │
│ ▼ │
│ Found? ──YES──► READ THE DOCUMENTATION │
│ │ │
│ NO │
│ ▼ │
│ 3. Check database schema │
│ cat prisma/schema.prisma | grep -i "related-table" │
│ │ │
│ ▼ │
│ Found? ──YES──► FEATURE LIKELY EXISTS │
│ │ │
│ NO │
│ ▼ │
│ 4. Check docker logs │
│ docker logs trading-bot-v4 | grep -i "feature" | tail │
│ │ │
│ ▼ │
│ Found? ──YES──► FEATURE IS ACTIVE │
│ │ │
│ NO │
│ ▼ │
│ 5. Check git history │
│ git log --oneline --all | grep -i "feature" | head -10 │
│ │ │
│ ▼ │
│ Found? ──YES──► MAY BE ARCHIVED/DISABLED │
│ │ │
│ NO │
│ ▼ │
│ FEATURE DOES NOT EXIST - SAFE TO BUILD │
└─────────────────────────────────────────────────────────────┘
```
### Why This Matters: Historical Examples
| Feature | Built Date | Trigger Event | Value |
|---------|------------|---------------|-------|
| **Stop Hunt Revenge** | Nov 20, 2025 | Quality 90 signal stopped out, missed $490 profit on 8.8% reversal | Captures reversal moves |
| **Adaptive Leverage** | Nov 24, 2025 | Quality 95+ signals had 100% win rate, wanted to scale winners | 2× profit on high quality |
| **HA Failover** | Nov 25, 2025 | Server went down during active trades | Zero-downtime protection |
| **Phantom Detection** | Nov 16, 2025 | Position opened with wrong size, no monitoring | Prevents unprotected positions |
| **BlockedSignal Tracker** | Nov 22, 2025 | Needed data to optimize quality thresholds | Data-driven threshold tuning |
**Don't rebuild what exists. Enhance what's proven.**
---
## ⚠️ CRITICAL: VERIFICATION MANDATE - READ THIS FIRST ⚠️
**THIS IS A REAL MONEY TRADING SYSTEM - EVERY CHANGE AFFECTS USER'S FINANCIAL FUTURE**

9
.gitignore vendored
View File

@@ -31,12 +31,6 @@ logs/
# Docker
.dockerignore
# Test files
*.test.ts
*.test.js
test-*.ts
test-*.js
# Temporary files
tmp/
temp/
@@ -45,3 +39,6 @@ temp/
# Build artifacts
dist/
.backtester/
# Coverage reports
coverage/

28
jest.config.js Normal file
View File

@@ -0,0 +1,28 @@
/** @type {import('jest').Config} */
const config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
useESM: true,
tsconfig: {
module: 'ESNext',
moduleResolution: 'node',
},
}],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
collectCoverageFrom: [
'lib/trading/position-manager.ts',
],
coverageReporters: ['text', 'text-summary', 'html'],
verbose: true,
testTimeout: 10000,
}
module.exports = config

3580
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,11 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit"
},
"dependencies": {
"@drift-labs/sdk": "^2.75.0",
@@ -30,9 +34,13 @@
},
"devDependencies": {
"@types/bn.js": "^5.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"jest": "^29.7.0",
"jest-junit": "^16.0.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.3.0"
},

224
tests/README.md Normal file
View File

@@ -0,0 +1,224 @@
# Position Manager Tests
## Overview
Comprehensive integration test suite for the Position Manager (`lib/trading/position-manager.ts`), which manages ~1,938 lines of critical trading logic handling real capital.
## Quick Start
```bash
# Run all tests
npm test
# Run tests in watch mode (development)
npm run test:watch
# Run tests with coverage report
npm run test:coverage
# Run tests for CI (with JUnit reporter)
npm run test:ci
```
## Test Structure
```
tests/
├── setup.ts # Global test configuration and mocks
├── helpers/
│ └── trade-factory.ts # Factory functions for creating mock trades
├── integration/
│ └── position-manager/
│ ├── tp1-detection.test.ts # Take Profit 1 detection tests
│ ├── breakeven-sl.test.ts # Breakeven SL after TP1 tests
│ ├── adx-runner-sl.test.ts # ADX-based runner SL tests
│ ├── trailing-stop.test.ts # Trailing stop functionality tests
│ ├── edge-cases.test.ts # Edge cases and common pitfalls
│ ├── price-verification.test.ts # Price verification before TP flags
│ └── decision-helpers.test.ts # Decision helper function tests
└── README.md # This file
```
## Test Coverage
These tests verify the **logic** of Position Manager functions in isolation:
- Decision helper functions (shouldStopLoss, shouldTakeProfit1, etc.)
- Price calculation functions (calculatePrice, calculateProfitPercent)
- ADX-based SL positioning logic
- Trailing stop mechanics
- Token vs USD conversion requirements
- Price verification requirements
**Note:** Coverage metrics are calculated against `lib/trading/position-manager.ts` but will be low because tests extract and validate logic patterns without importing the file directly. This avoids complex mocking while still validating critical trading logic.
The tests extract and validate the pure calculation logic without importing the actual Position Manager, avoiding complex mocking of:
- Drift blockchain connections
- Pyth price feeds
- Database operations
- WebSocket subscriptions
This approach:
1. Validates critical trading logic is correct
2. Prevents regression of known bugs (Common Pitfalls)
3. Enables safe refactoring of calculation functions
4. Runs quickly without external dependencies
## Test Data Standards
All tests use standardized test data based on actual trading conditions:
```typescript
TEST_DEFAULTS = {
entry: 140.00, // Entry price
atr: 0.43, // ATR value
adx: 26.9, // ADX (strong trend)
qualityScore: 95, // Signal quality
positionSize: 8000, // Position size USD
leverage: 15, // Leverage multiplier
// LONG targets
long: {
tp1: 141.20, // +0.86%
tp2: 142.41, // +1.72%
sl: 138.71, // -0.92%
emergencySl: 137.20, // -2%
},
// SHORT targets
short: {
tp1: 138.80, // -0.86%
tp2: 137.59, // -1.72%
sl: 141.29, // +0.92%
emergencySl: 142.80, // +2%
},
}
```
## Common Pitfalls Covered
These tests specifically prevent known bugs documented in the codebase:
| Pitfall # | Issue | Test File |
|-----------|--------------------------------------------|-----------------------------|
| #24 | Position.size as tokens, not USD | edge-cases.test.ts |
| #43 | TP1 false detection without price check | price-verification.test.ts |
| #45 | Wrong entry price for breakeven SL | breakeven-sl.test.ts |
| #52 | ADX-based runner SL positioning | adx-runner-sl.test.ts |
| #54 | MAE/MFE as percentages, not dollars | edge-cases.test.ts |
| #67 | Duplicate closures (atomic deduplication) | Covered by mock structure |
## Test Helpers
### Trade Factory (`tests/helpers/trade-factory.ts`)
```typescript
import { createLongTrade, createShortTrade, createTradeAfterTP1, createTradeAfterTP2 } from '../helpers/trade-factory'
// Create a basic LONG trade
const longTrade = createLongTrade()
// Create a SHORT trade with custom entry
const shortTrade = createShortTrade({ entryPrice: 150 })
// Create a trade after TP1 hit (40% runner remaining)
const runnerTrade = createTradeAfterTP1('long')
// Create a trade with trailing stop active
const trailingTrade = createTradeAfterTP2('short')
```
### Custom Matchers
```typescript
// Check if value is within a range
expect(0.86).toBeWithinRange(0.8, 0.9) // passes
```
## Why These Tests Matter
1. **Financial Protection**: Position Manager handles real money ($540+ capital). Bugs cost real dollars.
2. **Regression Prevention**: 71+ documented bugs in the codebase. Tests prevent reintroduction.
3. **Safe Refactoring**: With test coverage, code can be improved without fear of breaking existing functionality.
4. **Documentation**: Tests serve as executable documentation of expected behavior.
5. **CI/CD Pipeline**: Automated testing ensures changes don't break critical trading logic.
## Writing New Tests
### Guidelines
1. **Use Factories**: Always use `createLongTrade()` or `createShortTrade()` instead of manual object creation
2. **Test Both Directions**: Every price-based test should cover both LONG and SHORT positions
3. **Test Edge Cases**: Include boundary conditions and error scenarios
4. **Clear Names**: Test names should describe the exact behavior being tested
5. **Reference Pitfalls**: When testing a known bug, reference the pitfall number in comments
### Example Test
```typescript
import { createLongTrade, createShortTrade, TEST_DEFAULTS } from '../../helpers/trade-factory'
describe('New Feature', () => {
it('should handle LONG position correctly', () => {
const trade = createLongTrade()
// ... test logic
expect(result).toBe(expected)
})
it('should handle SHORT position correctly', () => {
const trade = createShortTrade()
// ... test logic
expect(result).toBe(expected)
})
// Reference known bugs
it('should NOT trigger false positive (Pitfall #XX)', () => {
// ... regression test
})
})
```
## Mocked Dependencies
The test setup mocks external dependencies to isolate tests:
- **Drift Service**: No actual blockchain calls
- **Pyth Price Monitor**: No WebSocket connections
- **Database Operations**: No actual DB queries
- **Telegram Notifications**: No actual messages sent
- **Drift Orders**: No actual order placement
## Running Specific Tests
```bash
# Run a specific test file
npm test -- tests/integration/position-manager/tp1-detection.test.ts
# Run tests matching a pattern
npm test -- --testNamePattern="LONG"
# Run tests in a specific directory
npm test -- tests/integration/position-manager/
```
## CI Integration
Tests run automatically in CI with:
- JUnit XML reports for test results
- Coverage reports in HTML and text formats
Run tests with coverage report:
```bash
npm run test:coverage
```
Run tests in CI mode with JUnit reporter:
```bash
npm run test:ci
```

View File

@@ -0,0 +1,247 @@
/**
* 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)
}
}

View File

@@ -0,0 +1,200 @@
/**
* ADX-Based Runner Stop Loss Tests
*
* Tests for ADX-based runner SL positioning after TP1 (Pitfall #52).
*
* Runner SL tiers based on ADX at entry:
* - ADX < 20: SL at 0% (breakeven) - Weak trend, preserve capital
* - ADX 20-25: SL at -0.3% - Moderate trend, some room
* - ADX > 25: SL at -0.55% - Strong trend, full retracement room
*/
import {
createLongTrade,
createShortTrade,
TEST_DEFAULTS,
calculateTargetPrice
} from '../../helpers/trade-factory'
describe('ADX-Based Runner Stop Loss', () => {
// Extract the ADX-based runner SL logic from Position Manager
function calculateRunnerSLPercent(adx: number): number {
if (adx < 20) {
return 0 // Weak trend: breakeven, preserve capital
} else if (adx < 25) {
return -0.3 // Moderate trend: some room
} else {
return -0.55 // Strong trend: full retracement room
}
}
function calculatePrice(
entryPrice: number,
percent: number,
direction: 'long' | 'short'
): number {
if (direction === 'long') {
return entryPrice * (1 + percent / 100)
} else {
return entryPrice * (1 - percent / 100)
}
}
describe('ADX < 20: Weak trend - breakeven SL', () => {
it('should return 0% SL for ADX 15 (weak trend)', () => {
const runnerSlPercent = calculateRunnerSLPercent(15)
expect(runnerSlPercent).toBe(0)
})
it('should return 0% SL for ADX 19.9 (boundary)', () => {
const runnerSlPercent = calculateRunnerSLPercent(19.9)
expect(runnerSlPercent).toBe(0)
})
it('LONG: should set runner SL at entry price for ADX 18', () => {
const trade = createLongTrade({ adx: 18, entryPrice: 140 })
const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!)
const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'long')
expect(runnerSlPercent).toBe(0)
expect(runnerSL).toBe(140.00) // Breakeven
})
it('SHORT: should set runner SL at entry price for ADX 18', () => {
const trade = createShortTrade({ adx: 18, entryPrice: 140 })
const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!)
const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'short')
expect(runnerSlPercent).toBe(0)
expect(runnerSL).toBe(140.00) // Breakeven
})
})
describe('ADX 20-25: Moderate trend - -0.3% SL', () => {
it('should return -0.3% SL for ADX 20 (boundary)', () => {
const runnerSlPercent = calculateRunnerSLPercent(20)
expect(runnerSlPercent).toBe(-0.3)
})
it('should return -0.3% SL for ADX 22', () => {
const runnerSlPercent = calculateRunnerSLPercent(22)
expect(runnerSlPercent).toBe(-0.3)
})
it('should return -0.3% SL for ADX 24.9 (boundary)', () => {
const runnerSlPercent = calculateRunnerSLPercent(24.9)
expect(runnerSlPercent).toBe(-0.3)
})
it('LONG: should set runner SL at -0.3% below entry for ADX 22', () => {
const trade = createLongTrade({ adx: 22, entryPrice: 140 })
const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!)
const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'long')
expect(runnerSlPercent).toBe(-0.3)
expect(runnerSL).toBeCloseTo(139.58, 2) // 140 * (1 - 0.003) = 139.58
expect(runnerSL).toBeLessThan(trade.entryPrice)
})
it('SHORT: should set runner SL at -0.3% above entry for ADX 22', () => {
const trade = createShortTrade({ adx: 22, entryPrice: 140 })
const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!)
const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'short')
expect(runnerSlPercent).toBe(-0.3)
expect(runnerSL).toBeCloseTo(140.42, 2) // 140 * (1 + 0.003) = 140.42
expect(runnerSL).toBeGreaterThan(trade.entryPrice)
})
})
describe('ADX > 25: Strong trend - -0.55% SL', () => {
it('should return -0.55% SL for ADX 25 (boundary)', () => {
const runnerSlPercent = calculateRunnerSLPercent(25)
expect(runnerSlPercent).toBe(-0.55)
})
it('should return -0.55% SL for ADX 26.9 (test default)', () => {
const runnerSlPercent = calculateRunnerSLPercent(TEST_DEFAULTS.adx)
expect(runnerSlPercent).toBe(-0.55)
})
it('should return -0.55% SL for ADX 35 (very strong)', () => {
const runnerSlPercent = calculateRunnerSLPercent(35)
expect(runnerSlPercent).toBe(-0.55)
})
it('LONG: should set runner SL at -0.55% below entry for ADX 26.9', () => {
const trade = createLongTrade({ adx: 26.9, entryPrice: 140 })
const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!)
const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'long')
expect(runnerSlPercent).toBe(-0.55)
expect(runnerSL).toBeCloseTo(139.23, 2) // 140 * (1 - 0.0055) = 139.23
expect(runnerSL).toBeLessThan(trade.entryPrice)
})
it('SHORT: should set runner SL at -0.55% above entry for ADX 26.9', () => {
const trade = createShortTrade({ adx: 26.9, entryPrice: 140 })
const runnerSlPercent = calculateRunnerSLPercent(trade.adxAtEntry!)
const runnerSL = calculatePrice(trade.entryPrice, runnerSlPercent, 'short')
expect(runnerSlPercent).toBe(-0.55)
expect(runnerSL).toBeCloseTo(140.77, 2) // 140 * (1 + 0.0055) = 140.77
expect(runnerSL).toBeGreaterThan(trade.entryPrice)
})
})
describe('Missing ADX handling', () => {
it('should default to 0% (breakeven) when ADX is 0', () => {
const runnerSlPercent = calculateRunnerSLPercent(0)
expect(runnerSlPercent).toBe(0) // Conservative default
})
it('should handle trades with no ADX data', () => {
// When ADX is undefined, the Position Manager uses 0 as default
// According to the logic: ADX < 20 = 0% SL (breakeven)
const adx = undefined
const adxValue = adx || 0
const runnerSlPercent = calculateRunnerSLPercent(adxValue)
// No ADX = 0 = weak trend = breakeven
expect(runnerSlPercent).toBe(0)
})
})
describe('Retracement room validation', () => {
it('LONG ADX 26.9: runner can handle -0.55% retracement without stop', () => {
const entryPrice = 140
const runnerSL = calculatePrice(entryPrice, -0.55, 'long')
// Price at -0.4% should NOT hit SL
const priceAt0_4PercentDrop = entryPrice * 0.996 // 139.44
expect(priceAt0_4PercentDrop).toBeGreaterThan(runnerSL)
// Price at -0.55% should hit SL
const priceAt0_55PercentDrop = runnerSL
expect(priceAt0_55PercentDrop).toBe(runnerSL)
// Price at -0.6% should definitely hit SL
const priceAt0_6PercentDrop = entryPrice * 0.994 // 139.16
expect(priceAt0_6PercentDrop).toBeLessThan(runnerSL)
})
it('SHORT ADX 26.9: runner can handle +0.55% retracement without stop', () => {
const entryPrice = 140
const runnerSL = calculatePrice(entryPrice, -0.55, 'short')
// Price at +0.4% should NOT hit SL
const priceAt0_4PercentRise = entryPrice * 1.004 // 140.56
expect(priceAt0_4PercentRise).toBeLessThan(runnerSL)
// Price at +0.55% should hit SL
const priceAt0_55PercentRise = runnerSL
expect(priceAt0_55PercentRise).toBe(runnerSL)
// Price at +0.6% should definitely hit SL
const priceAt0_6PercentRise = entryPrice * 1.006 // 140.84
expect(priceAt0_6PercentRise).toBeGreaterThan(runnerSL)
})
})
})

View File

@@ -0,0 +1,155 @@
/**
* Breakeven Stop Loss Tests
*
* Tests for SL movement to breakeven after TP1 hit.
*
* Key behaviors tested:
* - SL moves to entry price (breakeven) after TP1 for LONG
* - SL moves to entry price (breakeven) after TP1 for SHORT
* - CRITICAL (Pitfall #45): Must use DATABASE entry price, not Drift recalculated entry
*/
import {
createLongTrade,
createShortTrade,
createTradeAfterTP1,
TEST_DEFAULTS,
calculateTargetPrice
} from '../../helpers/trade-factory'
describe('Breakeven Stop Loss', () => {
// Test the calculatePrice logic extracted from Position Manager
function calculatePrice(
entryPrice: number,
percent: number,
direction: 'long' | 'short'
): number {
if (direction === 'long') {
return entryPrice * (1 + percent / 100)
} else {
return entryPrice * (1 - percent / 100)
}
}
describe('LONG positions after TP1', () => {
it('should calculate breakeven SL at entry price for LONG', () => {
const trade = createLongTrade({ entryPrice: 140.00 })
// After TP1, SL moves to breakeven (0%)
const breakevenSL = calculatePrice(trade.entryPrice, 0, 'long')
expect(breakevenSL).toBe(140.00)
})
it('should protect 60% profit when runner SL at breakeven', () => {
// After TP1 closes 60%, remaining 40% has SL at entry
const trade = createTradeAfterTP1('long')
expect(trade.tp1Hit).toBe(true)
expect(trade.slMovedToBreakeven).toBe(true)
expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.entry)
// Runner size should be 40% of original
expect(trade.currentSize).toBe(TEST_DEFAULTS.positionSize * 0.4)
})
it('should use DATABASE entry price, NOT Drift recalculated entry (Pitfall #45)', () => {
// CRITICAL: After partial close, Drift recalculates entry price based on remaining position
// This would give wrong breakeven SL. Must use ORIGINAL entry from database.
const databaseEntryPrice = 140.00
const driftRecalculatedEntry = 140.50 // Wrong! Drift adjusts after partial close
// Correct: Use database entry
const correctBreakevenSL = calculatePrice(databaseEntryPrice, 0, 'long')
expect(correctBreakevenSL).toBe(140.00)
// Wrong: Using Drift entry would give incorrect SL
const wrongBreakevenSL = calculatePrice(driftRecalculatedEntry, 0, 'long')
expect(wrongBreakevenSL).not.toBe(140.00)
expect(wrongBreakevenSL).toBe(140.50) // This would be wrong!
// The trade factory correctly uses original entry
const trade = createTradeAfterTP1('long', { entryPrice: databaseEntryPrice })
expect(trade.entryPrice).toBe(databaseEntryPrice)
expect(trade.stopLossPrice).toBe(databaseEntryPrice)
})
})
describe('SHORT positions after TP1', () => {
it('should calculate breakeven SL at entry price for SHORT', () => {
const trade = createShortTrade({ entryPrice: 140.00 })
// After TP1, SL moves to breakeven (0%)
const breakevenSL = calculatePrice(trade.entryPrice, 0, 'short')
expect(breakevenSL).toBe(140.00)
})
it('should protect 60% profit when runner SL at breakeven', () => {
const trade = createTradeAfterTP1('short')
expect(trade.tp1Hit).toBe(true)
expect(trade.slMovedToBreakeven).toBe(true)
expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.entry)
// Runner size should be 40% of original
expect(trade.currentSize).toBe(TEST_DEFAULTS.positionSize * 0.4)
})
it('should use DATABASE entry price, NOT Drift recalculated entry (Pitfall #45)', () => {
const databaseEntryPrice = 140.00
const driftRecalculatedEntry = 139.50 // Wrong! Drift adjusts after partial close
// Correct: Use database entry
const correctBreakevenSL = calculatePrice(databaseEntryPrice, 0, 'short')
expect(correctBreakevenSL).toBe(140.00)
// Wrong: Using Drift entry would give incorrect SL
const wrongBreakevenSL = calculatePrice(driftRecalculatedEntry, 0, 'short')
expect(wrongBreakevenSL).not.toBe(140.00)
// The trade factory correctly uses original entry
const trade = createTradeAfterTP1('short', { entryPrice: databaseEntryPrice })
expect(trade.entryPrice).toBe(databaseEntryPrice)
expect(trade.stopLossPrice).toBe(databaseEntryPrice)
})
})
describe('SL direction verification', () => {
it('LONG: breakeven SL should be BELOW entry when negative %', () => {
const entryPrice = 140.00
// For LONG: negative % = lower price = valid SL
const slAt0_3PercentLoss = calculatePrice(entryPrice, -0.3, 'long')
expect(slAt0_3PercentLoss).toBe(139.58) // 140 * (1 - 0.003)
expect(slAt0_3PercentLoss).toBeLessThan(entryPrice)
})
it('SHORT: breakeven SL should be ABOVE entry when negative %', () => {
const entryPrice = 140.00
// For SHORT: negative % = higher price = valid SL
const slAt0_3PercentLoss = calculatePrice(entryPrice, -0.3, 'short')
expect(slAt0_3PercentLoss).toBe(140.42) // 140 * (1 + 0.003)
expect(slAt0_3PercentLoss).toBeGreaterThan(entryPrice)
})
it('should verify SL moves in profitable direction', () => {
const longTrade = createLongTrade({ entryPrice: 140 })
const shortTrade = createShortTrade({ entryPrice: 140 })
// Original SLs are at loss levels
expect(longTrade.stopLossPrice).toBe(TEST_DEFAULTS.long.sl) // Below entry
expect(shortTrade.stopLossPrice).toBe(TEST_DEFAULTS.short.sl) // Above entry
// After TP1, SLs move to breakeven (entry price)
const longAfterTP1 = createTradeAfterTP1('long')
const shortAfterTP1 = createTradeAfterTP1('short')
// Both should now be at entry price
expect(longAfterTP1.stopLossPrice).toBe(TEST_DEFAULTS.entry)
expect(shortAfterTP1.stopLossPrice).toBe(TEST_DEFAULTS.entry)
})
})
})

View File

@@ -0,0 +1,308 @@
/**
* Decision Helpers Tests
*
* Tests for all decision helper functions in Position Manager:
* - shouldEmergencyStop() - LONG and SHORT
* - shouldStopLoss() - LONG and SHORT
* - shouldTakeProfit1() - LONG and SHORT
* - shouldTakeProfit2() - LONG and SHORT
*/
import {
createLongTrade,
createShortTrade,
TEST_DEFAULTS
} from '../../helpers/trade-factory'
describe('Decision Helpers', () => {
// Extract decision helpers from Position Manager
// These are pure functions that test price against targets
function shouldEmergencyStop(price: number, trade: { direction: 'long' | 'short', emergencyStopPrice: number }): boolean {
if (trade.direction === 'long') {
return price <= trade.emergencyStopPrice
} else {
return price >= trade.emergencyStopPrice
}
}
function shouldStopLoss(price: number, trade: { direction: 'long' | 'short', stopLossPrice: number }): boolean {
if (trade.direction === 'long') {
return price <= trade.stopLossPrice
} else {
return price >= trade.stopLossPrice
}
}
function shouldTakeProfit1(price: number, trade: { direction: 'long' | 'short', tp1Price: number }): boolean {
if (trade.direction === 'long') {
return price >= trade.tp1Price
} else {
return price <= trade.tp1Price
}
}
function shouldTakeProfit2(price: number, trade: { direction: 'long' | 'short', tp2Price: number }): boolean {
if (trade.direction === 'long') {
return price >= trade.tp2Price
} else {
return price <= trade.tp2Price
}
}
describe('shouldEmergencyStop()', () => {
describe('LONG positions', () => {
it('should trigger emergency stop when price at -2% (emergency level)', () => {
const trade = createLongTrade({ entryPrice: 140 })
// Emergency stop at -2% = $137.20
expect(trade.emergencyStopPrice).toBe(TEST_DEFAULTS.long.emergencySl)
expect(shouldEmergencyStop(137.20, trade)).toBe(true)
})
it('should trigger emergency stop when price below emergency level', () => {
const trade = createLongTrade()
expect(shouldEmergencyStop(136.00, trade)).toBe(true) // Below emergency
expect(shouldEmergencyStop(135.00, trade)).toBe(true) // Well below
})
it('should NOT trigger emergency stop above emergency level', () => {
const trade = createLongTrade()
expect(shouldEmergencyStop(138.00, trade)).toBe(false) // Above emergency but below SL
expect(shouldEmergencyStop(140.00, trade)).toBe(false) // At entry
expect(shouldEmergencyStop(141.00, trade)).toBe(false) // In profit
})
})
describe('SHORT positions', () => {
it('should trigger emergency stop when price at +2% (emergency level)', () => {
const trade = createShortTrade({ entryPrice: 140 })
// Emergency stop at +2% = $142.80
expect(trade.emergencyStopPrice).toBe(TEST_DEFAULTS.short.emergencySl)
expect(shouldEmergencyStop(142.80, trade)).toBe(true)
})
it('should trigger emergency stop when price above emergency level', () => {
const trade = createShortTrade()
expect(shouldEmergencyStop(143.00, trade)).toBe(true)
expect(shouldEmergencyStop(145.00, trade)).toBe(true)
})
it('should NOT trigger emergency stop below emergency level', () => {
const trade = createShortTrade()
expect(shouldEmergencyStop(142.00, trade)).toBe(false) // Below emergency but at SL
expect(shouldEmergencyStop(140.00, trade)).toBe(false) // At entry
expect(shouldEmergencyStop(138.00, trade)).toBe(false) // In profit
})
})
})
describe('shouldStopLoss()', () => {
describe('LONG positions', () => {
it('should trigger SL when price at stop loss level', () => {
const trade = createLongTrade({ entryPrice: 140 })
// SL at -0.92% = $138.71
expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.long.sl)
expect(shouldStopLoss(138.71, trade)).toBe(true)
})
it('should trigger SL when price below stop loss', () => {
const trade = createLongTrade()
expect(shouldStopLoss(138.00, trade)).toBe(true)
expect(shouldStopLoss(137.50, trade)).toBe(true)
})
it('should NOT trigger SL when price above stop loss', () => {
const trade = createLongTrade()
expect(shouldStopLoss(139.00, trade)).toBe(false)
expect(shouldStopLoss(140.00, trade)).toBe(false)
expect(shouldStopLoss(141.00, trade)).toBe(false)
})
it('should work with adjusted SL (breakeven after TP1)', () => {
const trade = createLongTrade({
entryPrice: 140,
stopLossPrice: 140.00, // Moved to breakeven
tp1Hit: true
})
expect(shouldStopLoss(139.99, trade)).toBe(true) // Just below breakeven
expect(shouldStopLoss(140.00, trade)).toBe(true) // At breakeven
expect(shouldStopLoss(140.01, trade)).toBe(false) // Above breakeven
})
})
describe('SHORT positions', () => {
it('should trigger SL when price at stop loss level', () => {
const trade = createShortTrade({ entryPrice: 140 })
// SL at +0.92% = $141.29
expect(trade.stopLossPrice).toBe(TEST_DEFAULTS.short.sl)
expect(shouldStopLoss(141.29, trade)).toBe(true)
})
it('should trigger SL when price above stop loss', () => {
const trade = createShortTrade()
expect(shouldStopLoss(142.00, trade)).toBe(true)
expect(shouldStopLoss(143.00, trade)).toBe(true)
})
it('should NOT trigger SL when price below stop loss', () => {
const trade = createShortTrade()
expect(shouldStopLoss(141.00, trade)).toBe(false)
expect(shouldStopLoss(140.00, trade)).toBe(false)
expect(shouldStopLoss(138.00, trade)).toBe(false)
})
it('should work with adjusted SL (breakeven after TP1)', () => {
const trade = createShortTrade({
entryPrice: 140,
stopLossPrice: 140.00, // Moved to breakeven
tp1Hit: true
})
expect(shouldStopLoss(140.01, trade)).toBe(true) // Just above breakeven
expect(shouldStopLoss(140.00, trade)).toBe(true) // At breakeven
expect(shouldStopLoss(139.99, trade)).toBe(false) // Below breakeven (in profit)
})
})
})
describe('shouldTakeProfit1()', () => {
describe('LONG positions', () => {
it('should trigger TP1 when price at target', () => {
const trade = createLongTrade({ entryPrice: 140 })
expect(trade.tp1Price).toBe(TEST_DEFAULTS.long.tp1)
expect(shouldTakeProfit1(141.20, trade)).toBe(true)
})
it('should trigger TP1 when price above target', () => {
const trade = createLongTrade()
expect(shouldTakeProfit1(141.50, trade)).toBe(true)
expect(shouldTakeProfit1(142.00, trade)).toBe(true)
})
it('should NOT trigger TP1 when price below target', () => {
const trade = createLongTrade()
expect(shouldTakeProfit1(141.00, trade)).toBe(false)
expect(shouldTakeProfit1(140.00, trade)).toBe(false)
expect(shouldTakeProfit1(139.00, trade)).toBe(false)
})
})
describe('SHORT positions', () => {
it('should trigger TP1 when price at target', () => {
const trade = createShortTrade({ entryPrice: 140 })
expect(trade.tp1Price).toBe(TEST_DEFAULTS.short.tp1)
expect(shouldTakeProfit1(138.80, trade)).toBe(true)
})
it('should trigger TP1 when price below target (better for short)', () => {
const trade = createShortTrade()
expect(shouldTakeProfit1(138.50, trade)).toBe(true)
expect(shouldTakeProfit1(138.00, trade)).toBe(true)
})
it('should NOT trigger TP1 when price above target', () => {
const trade = createShortTrade()
expect(shouldTakeProfit1(139.00, trade)).toBe(false)
expect(shouldTakeProfit1(140.00, trade)).toBe(false)
expect(shouldTakeProfit1(141.00, trade)).toBe(false)
})
})
})
describe('shouldTakeProfit2()', () => {
describe('LONG positions', () => {
it('should trigger TP2 when price at target', () => {
const trade = createLongTrade({ entryPrice: 140 })
expect(trade.tp2Price).toBe(TEST_DEFAULTS.long.tp2)
expect(shouldTakeProfit2(142.41, trade)).toBe(true)
})
it('should trigger TP2 when price above target', () => {
const trade = createLongTrade()
expect(shouldTakeProfit2(143.00, trade)).toBe(true)
expect(shouldTakeProfit2(145.00, trade)).toBe(true)
})
it('should NOT trigger TP2 when price below target', () => {
const trade = createLongTrade()
expect(shouldTakeProfit2(142.00, trade)).toBe(false)
expect(shouldTakeProfit2(141.00, trade)).toBe(false)
expect(shouldTakeProfit2(140.00, trade)).toBe(false)
})
})
describe('SHORT positions', () => {
it('should trigger TP2 when price at target', () => {
const trade = createShortTrade({ entryPrice: 140 })
expect(trade.tp2Price).toBe(TEST_DEFAULTS.short.tp2)
expect(shouldTakeProfit2(137.59, trade)).toBe(true)
})
it('should trigger TP2 when price below target (better for short)', () => {
const trade = createShortTrade()
expect(shouldTakeProfit2(137.00, trade)).toBe(true)
expect(shouldTakeProfit2(135.00, trade)).toBe(true)
})
it('should NOT trigger TP2 when price above target', () => {
const trade = createShortTrade()
expect(shouldTakeProfit2(138.00, trade)).toBe(false)
expect(shouldTakeProfit2(140.00, trade)).toBe(false)
expect(shouldTakeProfit2(141.00, trade)).toBe(false)
})
})
})
describe('Decision order priority', () => {
it('emergency stop should trigger before regular SL', () => {
const trade = createLongTrade({ entryPrice: 140 })
const price = 136.00 // Below both emergency and SL
const emergencyTriggered = shouldEmergencyStop(price, trade)
const slTriggered = shouldStopLoss(price, trade)
expect(emergencyTriggered).toBe(true)
expect(slTriggered).toBe(true)
// In Position Manager, emergency is checked first (higher priority)
})
it('SL should NOT be checked if TP already hit the threshold', () => {
const trade = createLongTrade({ entryPrice: 140 })
const highPrice = 143.00 // Well above TP1 and TP2
const tp1Triggered = shouldTakeProfit1(highPrice, trade)
const tp2Triggered = shouldTakeProfit2(highPrice, trade)
const slTriggered = shouldStopLoss(highPrice, trade)
expect(tp1Triggered).toBe(true)
expect(tp2Triggered).toBe(true)
expect(slTriggered).toBe(false)
// When price is high, TP triggers but not SL
})
})
})

View File

@@ -0,0 +1,241 @@
/**
* Edge Cases Tests
*
* Tests for edge cases and common pitfalls in Position Manager.
*
* Pitfalls tested:
* - #24: Position.size as tokens, not USD
* - #54: MAE/MFE as percentages, not dollars
* - Phantom trade detection (< 50% expected size)
* - Profit percent calculation for LONG and SHORT
*/
import {
createLongTrade,
createShortTrade,
TEST_DEFAULTS,
calculateExpectedProfitPercent
} from '../../helpers/trade-factory'
describe('Edge Cases', () => {
describe('Position.size tokens vs USD (Pitfall #24)', () => {
it('should convert token size to USD correctly', () => {
// Drift SDK returns position.size in BASE ASSET TOKENS (e.g., 12.28 SOL)
// NOT in USD ($1,950)
const positionSizeTokens = 12.28 // SOL tokens
const currentPrice = 159.12 // Current SOL price
// CORRECT: Convert tokens to USD
const positionSizeUSD = Math.abs(positionSizeTokens) * currentPrice
expect(positionSizeUSD).toBeCloseTo(1953.59, 0)
// WRONG: Using tokens directly as USD (off by 159x!)
expect(positionSizeTokens).not.toBe(positionSizeUSD)
})
it('should detect TP1 using USD values, not token values', () => {
// Bug: Comparing tokens (12.28) to USD ($1,950) caused false TP1 detection
// 12.28 < 1950 * 0.95 was always true!
const trackedSizeUSD = 1950
const positionSizeTokens = 12.28
const currentPrice = 159.12
// WRONG: Direct comparison (would always think 95% was reduced)
const wrongComparison = positionSizeTokens < trackedSizeUSD * 0.95
expect(wrongComparison).toBe(true) // BUG: False positive!
// CORRECT: Convert to USD first
const positionSizeUSD = Math.abs(positionSizeTokens) * currentPrice
const correctComparison = positionSizeUSD < trackedSizeUSD * 0.95
expect(correctComparison).toBe(false) // Position is actually ~100%, not reduced
})
it('should calculate size reduction correctly using USD', () => {
const originalSizeUSD = 8000
const tp1SizePercent = 60
// After TP1, 60% closed, 40% remaining
const expectedRemainingUSD = originalSizeUSD * (1 - tp1SizePercent / 100)
expect(expectedRemainingUSD).toBe(3200)
// Token equivalent at $140/SOL
const tokensRemaining = expectedRemainingUSD / 140
expect(tokensRemaining).toBeCloseTo(22.86, 1)
// Verify conversion back to USD
const convertedBackUSD = tokensRemaining * 140
expect(convertedBackUSD).toBeCloseTo(expectedRemainingUSD, 0)
})
})
describe('Phantom trade detection (< 50% expected size)', () => {
it('should detect phantom when actual size < 50% of expected', () => {
const expectedSizeUSD = 8000
const actualSizeUSD = 1370 // Only 17% filled
const sizeRatio = actualSizeUSD / expectedSizeUSD
const isPhantom = sizeRatio < 0.5
expect(sizeRatio).toBeCloseTo(0.171, 2)
expect(isPhantom).toBe(true)
})
it('should NOT detect phantom when size >= 50%', () => {
const expectedSizeUSD = 8000
const actualSizeUSD = 4500 // 56% filled
const sizeRatio = actualSizeUSD / expectedSizeUSD
const isPhantom = sizeRatio < 0.5
expect(sizeRatio).toBeCloseTo(0.5625, 2)
expect(isPhantom).toBe(false)
})
it('should handle exact 50% boundary', () => {
const expectedSizeUSD = 8000
const actualSizeUSD = 4000 // Exactly 50%
const sizeRatio = actualSizeUSD / expectedSizeUSD
const isPhantom = sizeRatio < 0.5
expect(sizeRatio).toBe(0.5)
expect(isPhantom).toBe(false) // 50% is acceptable
})
it('should NOT flag runner after TP1 as phantom', () => {
// Bug: After TP1, currentSize is 40% of original
// This should NOT be flagged as phantom
const trade = createLongTrade({ tp1Hit: true })
trade.currentSize = trade.positionSize * 0.4 // 40% remaining
// Phantom check should ONLY run on initial position, not after TP1
const isAfterTP1 = trade.tp1Hit
const sizeRatio = trade.currentSize / trade.positionSize
// Even though size is <50%, this is NOT a phantom - it's a runner
const isPhantom = !isAfterTP1 && sizeRatio < 0.5
expect(isAfterTP1).toBe(true)
expect(sizeRatio).toBe(0.4)
expect(isPhantom).toBe(false) // Correctly NOT flagged as phantom
})
})
describe('MAE/MFE as percentages (Pitfall #54)', () => {
it('should track MFE as percentage, not dollars', () => {
const trade = createLongTrade({ entryPrice: 140 })
const bestPrice = 141.20 // +0.86%
// CORRECT: Store as percentage
const mfePercent = ((bestPrice - trade.entryPrice) / trade.entryPrice) * 100
expect(mfePercent).toBeCloseTo(0.857, 1)
// Database expects small % values like 0.86, not $68
expect(mfePercent).toBeLessThan(5) // Sanity check: percentage is small
})
it('should track MAE as percentage, not dollars', () => {
const trade = createLongTrade({ entryPrice: 140 })
const worstPrice = 138.60 // -1%
// CORRECT: Store as percentage (negative for loss)
const maePercent = ((worstPrice - trade.entryPrice) / trade.entryPrice) * 100
expect(maePercent).toBeCloseTo(-1.0, 1)
// MAE should be negative for adverse movement
expect(maePercent).toBeLessThan(0)
})
it('should update MFE when profit increases', () => {
const trade = createLongTrade({ entryPrice: 140 })
trade.maxFavorableExcursion = 0
// Price moves to +0.5%, then +1%
const prices = [140.70, 141.40]
for (const price of prices) {
const profitPercent = ((price - trade.entryPrice) / trade.entryPrice) * 100
if (profitPercent > trade.maxFavorableExcursion) {
trade.maxFavorableExcursion = profitPercent
trade.maxFavorablePrice = price
}
}
expect(trade.maxFavorableExcursion).toBeCloseTo(1.0, 1) // +1%
expect(trade.maxFavorablePrice).toBe(141.40)
})
it('should update MAE when loss increases', () => {
const trade = createLongTrade({ entryPrice: 140 })
trade.maxAdverseExcursion = 0
// Price moves to -0.3%, then -0.5%
const prices = [139.58, 139.30]
for (const price of prices) {
const profitPercent = ((price - trade.entryPrice) / trade.entryPrice) * 100
if (profitPercent < trade.maxAdverseExcursion) {
trade.maxAdverseExcursion = profitPercent
trade.maxAdversePrice = price
}
}
expect(trade.maxAdverseExcursion).toBeCloseTo(-0.5, 1) // -0.5%
expect(trade.maxAdversePrice).toBe(139.30)
})
it('SHORT: should calculate MFE correctly (positive when price drops)', () => {
const trade = createShortTrade({ entryPrice: 140 })
const bestPrice = 138.60 // -1% = good for SHORT
// For SHORT, profit when price drops
const mfePercent = ((trade.entryPrice - bestPrice) / trade.entryPrice) * 100
expect(mfePercent).toBeCloseTo(1.0, 1) // +1% profit for short
expect(mfePercent).toBeGreaterThan(0)
})
it('SHORT: should calculate MAE correctly (negative when price rises)', () => {
const trade = createShortTrade({ entryPrice: 140 })
const worstPrice = 141.40 // +1% = bad for SHORT
// For SHORT, loss when price rises
const maePercent = ((trade.entryPrice - worstPrice) / trade.entryPrice) * 100
expect(maePercent).toBeCloseTo(-1.0, 1) // -1% loss for short
expect(maePercent).toBeLessThan(0)
})
})
describe('Profit percent calculation', () => {
it('LONG: positive profit when price increases', () => {
const profit = calculateExpectedProfitPercent(140, 141.20, 'long')
expect(profit).toBeCloseTo(0.857, 1)
expect(profit).toBeGreaterThan(0)
})
it('LONG: negative profit when price decreases', () => {
const profit = calculateExpectedProfitPercent(140, 138.80, 'long')
expect(profit).toBeCloseTo(-0.857, 1)
expect(profit).toBeLessThan(0)
})
it('SHORT: positive profit when price decreases', () => {
const profit = calculateExpectedProfitPercent(140, 138.80, 'short')
expect(profit).toBeCloseTo(0.857, 1)
expect(profit).toBeGreaterThan(0)
})
it('SHORT: negative profit when price increases', () => {
const profit = calculateExpectedProfitPercent(140, 141.20, 'short')
expect(profit).toBeCloseTo(-0.857, 1)
expect(profit).toBeLessThan(0)
})
it('should return 0 when price equals entry', () => {
expect(calculateExpectedProfitPercent(140, 140, 'long')).toBe(0)
expect(calculateExpectedProfitPercent(140, 140, 'short')).toBe(0)
})
})
})

View File

@@ -0,0 +1,197 @@
/**
* Price Verification Tests
*
* Tests for price verification before setting TP flags.
*
* Key behaviors tested (Pitfall #43):
* - Verify price reached TP1 before setting tp1Hit flag
* - Require BOTH size reduced AND price at target
* - Use tolerance for price target matching (0.2%)
*/
import {
createLongTrade,
createShortTrade,
TEST_DEFAULTS
} from '../../helpers/trade-factory'
describe('Price Verification', () => {
// Extract isPriceAtTarget logic from Position Manager
function isPriceAtTarget(currentPrice: number, targetPrice: number, tolerance: number = 0.002): boolean {
if (!targetPrice || targetPrice === 0) return false
const diff = Math.abs(currentPrice - targetPrice) / targetPrice
return diff <= tolerance
}
function shouldTakeProfit1(price: number, trade: { direction: 'long' | 'short', tp1Price: number }): boolean {
if (trade.direction === 'long') {
return price >= trade.tp1Price
} else {
return price <= trade.tp1Price
}
}
describe('TP1 verification before setting flag (Pitfall #43)', () => {
it('LONG: should require BOTH size reduction AND price at TP1', () => {
const trade = createLongTrade({ entryPrice: 140, tp1Price: 141.20 })
// Scenario: Size reduced but price NOT at TP1
const sizeReduced = true
const currentPrice = 140.50 // Below TP1
const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price)
// Should NOT set tp1Hit because price hasn't reached target
const shouldSetTP1 = sizeReduced && priceAtTP1
expect(shouldSetTP1).toBe(false)
// This was likely a MANUAL CLOSE, not TP1
})
it('LONG: should allow TP1 when both conditions met', () => {
const trade = createLongTrade({ entryPrice: 140, tp1Price: 141.20 })
// Scenario: Size reduced AND price at TP1
const sizeReduced = true
const currentPrice = 141.20
const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price)
const shouldSetTP1 = sizeReduced && priceAtTP1
expect(shouldSetTP1).toBe(true)
})
it('SHORT: should require BOTH size reduction AND price at TP1', () => {
const trade = createShortTrade({ entryPrice: 140, tp1Price: 138.80 })
// Scenario: Size reduced but price NOT at TP1
const sizeReduced = true
const currentPrice = 139.50 // Above TP1 (short profits when price drops)
const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price)
const shouldSetTP1 = sizeReduced && priceAtTP1
expect(shouldSetTP1).toBe(false)
})
it('SHORT: should allow TP1 when both conditions met', () => {
const trade = createShortTrade({ entryPrice: 140, tp1Price: 138.80 })
const sizeReduced = true
const currentPrice = 138.80
const priceAtTP1 = isPriceAtTarget(currentPrice, trade.tp1Price)
const shouldSetTP1 = sizeReduced && priceAtTP1
expect(shouldSetTP1).toBe(true)
})
})
describe('isPriceAtTarget tolerance (0.2%)', () => {
it('should return true when price exactly at target', () => {
const result = isPriceAtTarget(141.20, 141.20)
expect(result).toBe(true)
})
it('should return true when price within 0.2% of target', () => {
// 0.2% of 141.20 = 0.2824
// So prices from 140.92 to 141.48 should be "at target"
expect(isPriceAtTarget(141.10, 141.20)).toBe(true) // -0.07%
expect(isPriceAtTarget(141.30, 141.20)).toBe(true) // +0.07%
expect(isPriceAtTarget(141.00, 141.20)).toBe(true) // -0.14%
expect(isPriceAtTarget(141.40, 141.20)).toBe(true) // +0.14%
})
it('should return false when price outside 0.2% tolerance', () => {
// More than 0.2% away should NOT be "at target"
expect(isPriceAtTarget(140.70, 141.20)).toBe(false) // -0.35%
expect(isPriceAtTarget(141.60, 141.20)).toBe(false) // +0.28%
expect(isPriceAtTarget(140.00, 141.20)).toBe(false) // -0.85%
})
it('should handle edge case of 0 target price', () => {
const result = isPriceAtTarget(141.20, 0)
expect(result).toBe(false)
})
it('should handle custom tolerance', () => {
// Use stricter 0.1% tolerance
expect(isPriceAtTarget(141.10, 141.20, 0.001)).toBe(true) // 0.07% < 0.1%
expect(isPriceAtTarget(141.00, 141.20, 0.001)).toBe(false) // 0.14% > 0.1%
// Use looser 0.5% tolerance
expect(isPriceAtTarget(140.50, 141.20, 0.005)).toBe(true) // 0.5% = 0.5%
expect(isPriceAtTarget(140.00, 141.20, 0.005)).toBe(false) // 0.85% > 0.5%
})
})
describe('shouldTakeProfit1 with price verification', () => {
it('LONG: combined size + price verification', () => {
const trade = createLongTrade({
entryPrice: 140,
tp1Price: 141.20,
positionSize: 8000
})
// Simulate Position Manager logic
const originalSize = trade.positionSize
const currentSize = 3200 // After 60% close
const sizeMismatch = currentSize < originalSize * 0.9
// Case 1: Price at TP1
const priceAtTP1 = 141.20
const priceReachedTP1 = shouldTakeProfit1(priceAtTP1, trade)
expect(sizeMismatch && priceReachedTP1).toBe(true) // Valid TP1
// Case 2: Price NOT at TP1
const priceNotAtTP1 = 140.50
const priceReachedTP1_2 = shouldTakeProfit1(priceNotAtTP1, trade)
expect(sizeMismatch && priceReachedTP1_2).toBe(false) // Invalid - manual close
})
it('SHORT: combined size + price verification', () => {
const trade = createShortTrade({
entryPrice: 140,
tp1Price: 138.80,
positionSize: 8000
})
const originalSize = trade.positionSize
const currentSize = 3200
const sizeMismatch = currentSize < originalSize * 0.9
// Case 1: Price at TP1 (below entry for short)
const priceAtTP1 = 138.80
const priceReachedTP1 = shouldTakeProfit1(priceAtTP1, trade)
expect(sizeMismatch && priceReachedTP1).toBe(true)
// Case 2: Price NOT at TP1 (still above target)
const priceNotAtTP1 = 139.50
const priceReachedTP1_2 = shouldTakeProfit1(priceNotAtTP1, trade)
expect(sizeMismatch && priceReachedTP1_2).toBe(false)
})
})
describe('TP2 price verification', () => {
function shouldTakeProfit2(price: number, trade: { direction: 'long' | 'short', tp2Price: number }): boolean {
if (trade.direction === 'long') {
return price >= trade.tp2Price
} else {
return price <= trade.tp2Price
}
}
it('LONG: should detect TP2 at correct price', () => {
const trade = createLongTrade({ tp2Price: 142.41 })
expect(shouldTakeProfit2(142.00, trade)).toBe(false) // Below
expect(shouldTakeProfit2(142.41, trade)).toBe(true) // At target
expect(shouldTakeProfit2(143.00, trade)).toBe(true) // Above
})
it('SHORT: should detect TP2 at correct price', () => {
const trade = createShortTrade({ tp2Price: 137.59 })
expect(shouldTakeProfit2(138.00, trade)).toBe(false) // Above (bad for short)
expect(shouldTakeProfit2(137.59, trade)).toBe(true) // At target
expect(shouldTakeProfit2(137.00, trade)).toBe(true) // Below (better for short)
})
})
})

View File

@@ -0,0 +1,177 @@
/**
* TP1 Detection Tests
*
* Tests for Take Profit 1 detection in Position Manager.
* TP1 is calculated as ATR × 2.0 (typically ~0.86% at ATR 0.43)
*
* Key behaviors tested:
* - LONG: TP1 triggers when price >= tp1Price
* - SHORT: TP1 triggers when price <= tp1Price
* - Must NOT trigger below threshold
* - Must NOT trigger when price moves against position
*/
import {
createLongTrade,
createShortTrade,
TEST_DEFAULTS,
calculateExpectedProfitPercent
} from '../../helpers/trade-factory'
describe('TP1 Detection', () => {
// Test the shouldTakeProfit1 logic extracted from Position Manager
// We test the pure calculation logic rather than the full Position Manager
function shouldTakeProfit1(price: number, trade: { direction: 'long' | 'short', tp1Price: number }): boolean {
if (trade.direction === 'long') {
return price >= trade.tp1Price
} else {
return price <= trade.tp1Price
}
}
describe('LONG positions', () => {
it('should detect TP1 when price reaches +0.86% (ATR 0.43 × 2.0)', () => {
// Test data: entry $140, TP1 at $141.20 (+0.86%)
const trade = createLongTrade()
expect(trade.tp1Price).toBe(TEST_DEFAULTS.long.tp1)
// Price at TP1 should trigger
const result = shouldTakeProfit1(141.20, trade)
expect(result).toBe(true)
// Verify profit calculation
const profitPercent = calculateExpectedProfitPercent(140, 141.20, 'long')
expect(profitPercent).toBeCloseTo(0.857, 1) // ~0.86%
})
it('should detect TP1 when price exceeds tp1Price', () => {
const trade = createLongTrade()
// Price above TP1 should also trigger
const result = shouldTakeProfit1(142.00, trade)
expect(result).toBe(true)
})
it('should NOT detect TP1 when price is below threshold (+0.5%)', () => {
const trade = createLongTrade()
// Price at +0.5% ($140.70) should NOT trigger TP1
const result = shouldTakeProfit1(140.70, trade)
expect(result).toBe(false)
// Verify profit percent is below TP1 threshold
const profitPercent = calculateExpectedProfitPercent(140, 140.70, 'long')
expect(profitPercent).toBeCloseTo(0.5, 2)
expect(profitPercent).toBeLessThan(0.86)
})
it('should NOT detect TP1 when price moves against position (negative)', () => {
const trade = createLongTrade()
// Price drop for LONG should NOT trigger TP1
const result = shouldTakeProfit1(139.00, trade)
expect(result).toBe(false)
// Verify this is a loss
const profitPercent = calculateExpectedProfitPercent(140, 139.00, 'long')
expect(profitPercent).toBeLessThan(0)
})
it('should NOT detect TP1 at entry price', () => {
const trade = createLongTrade()
const result = shouldTakeProfit1(140.00, trade)
expect(result).toBe(false)
})
})
describe('SHORT positions', () => {
it('should detect TP1 when price reaches -0.86% (ATR 0.43 × 2.0)', () => {
// Test data: entry $140, TP1 at $138.80 (-0.86%)
const trade = createShortTrade()
expect(trade.tp1Price).toBe(TEST_DEFAULTS.short.tp1)
// Price at TP1 should trigger
const result = shouldTakeProfit1(138.80, trade)
expect(result).toBe(true)
// Verify profit calculation (SHORT profits when price drops)
const profitPercent = calculateExpectedProfitPercent(140, 138.80, 'short')
expect(profitPercent).toBeCloseTo(0.857, 1) // ~0.86%
})
it('should detect TP1 when price is below tp1Price', () => {
const trade = createShortTrade()
// Price below TP1 should also trigger (better for short)
const result = shouldTakeProfit1(138.00, trade)
expect(result).toBe(true)
})
it('should NOT detect TP1 when price is above threshold (-0.5%)', () => {
const trade = createShortTrade()
// Price at -0.5% ($139.30) should NOT trigger TP1
const result = shouldTakeProfit1(139.30, trade)
expect(result).toBe(false)
// Verify profit percent is below TP1 threshold
const profitPercent = calculateExpectedProfitPercent(140, 139.30, 'short')
expect(profitPercent).toBeCloseTo(0.5, 2)
expect(profitPercent).toBeLessThan(0.86)
})
it('should NOT detect TP1 when price moves against position (rises)', () => {
const trade = createShortTrade()
// Price rise for SHORT should NOT trigger TP1
const result = shouldTakeProfit1(141.00, trade)
expect(result).toBe(false)
// Verify this is a loss for SHORT
const profitPercent = calculateExpectedProfitPercent(140, 141.00, 'short')
expect(profitPercent).toBeLessThan(0)
})
it('should NOT detect TP1 at entry price', () => {
const trade = createShortTrade()
const result = shouldTakeProfit1(140.00, trade)
expect(result).toBe(false)
})
})
describe('Edge cases', () => {
it('should handle exact TP1 price (boundary condition)', () => {
const longTrade = createLongTrade()
const shortTrade = createShortTrade()
// Exact TP1 price should trigger
expect(shouldTakeProfit1(longTrade.tp1Price, longTrade)).toBe(true)
expect(shouldTakeProfit1(shortTrade.tp1Price, shortTrade)).toBe(true)
})
it('should handle custom TP1 prices', () => {
const trade = createLongTrade({ tp1Price: 145.00 })
expect(trade.tp1Price).toBe(145.00)
expect(shouldTakeProfit1(144.99, trade)).toBe(false)
expect(shouldTakeProfit1(145.00, trade)).toBe(true)
expect(shouldTakeProfit1(145.01, trade)).toBe(true)
})
it('should work with different entry prices', () => {
// ETH-PERP style entry at $3500
const trade = createLongTrade({
entryPrice: 3500,
tp1Price: 3530, // +0.86%
})
expect(shouldTakeProfit1(3529, trade)).toBe(false)
expect(shouldTakeProfit1(3530, trade)).toBe(true)
})
})
})

View File

@@ -0,0 +1,271 @@
/**
* Trailing Stop Tests
*
* Tests for trailing stop functionality after TP2 trigger.
*
* Key behaviors tested:
* - Trailing stop activates after TP2 trigger
* - Calculate ATR-based trailing distance (1.5x ATR)
* - Update peak price tracking as price moves favorably
* - Trigger exit when price falls below trailing stop
*/
import {
createLongTrade,
createShortTrade,
createTradeAfterTP2,
TEST_DEFAULTS
} from '../../helpers/trade-factory'
describe('Trailing Stop', () => {
// Extract trailing stop calculation logic from Position Manager
function calculateTrailingStopPrice(
peakPrice: number,
trailingDistancePercent: number,
direction: 'long' | 'short'
): number {
if (direction === 'long') {
return peakPrice * (1 - trailingDistancePercent / 100)
} else {
return peakPrice * (1 + trailingDistancePercent / 100)
}
}
function calculateAtrBasedTrailingDistance(
atr: number,
currentPrice: number,
multiplier: number,
minPercent: number,
maxPercent: number
): number {
const atrPercent = (atr / currentPrice) * 100
const rawDistance = atrPercent * multiplier
return Math.max(minPercent, Math.min(maxPercent, rawDistance))
}
function shouldStopLoss(price: number, trade: { direction: 'long' | 'short', stopLossPrice: number }): boolean {
if (trade.direction === 'long') {
return price <= trade.stopLossPrice
} else {
return price >= trade.stopLossPrice
}
}
describe('Trailing stop activation', () => {
it('should activate trailing stop after TP2 trigger', () => {
const trade = createTradeAfterTP2('long')
expect(trade.tp2Hit).toBe(true)
expect(trade.trailingStopActive).toBe(true)
})
it('should NOT have trailing stop before TP2', () => {
const trade = createLongTrade()
expect(trade.tp2Hit).toBe(false)
expect(trade.trailingStopActive).toBe(false)
})
it('should NOT have trailing stop after TP1 only', () => {
const trade = createLongTrade({ tp1Hit: true })
expect(trade.tp1Hit).toBe(true)
expect(trade.tp2Hit).toBe(false)
expect(trade.trailingStopActive).toBe(false)
})
})
describe('ATR-based trailing distance calculation', () => {
it('should calculate trailing distance as 1.5x ATR', () => {
// ATR 0.43 at price $140
const atr = TEST_DEFAULTS.atr
const currentPrice = TEST_DEFAULTS.entry
const multiplier = 1.5
const minPercent = 0.25
const maxPercent = 0.9
const distance = calculateAtrBasedTrailingDistance(
atr, currentPrice, multiplier, minPercent, maxPercent
)
// 0.43 / 140 * 100 = 0.307% * 1.5 = 0.46%
expect(distance).toBeCloseTo(0.46, 1)
})
it('should clamp trailing distance to minimum', () => {
// Very low ATR should clamp to min
const atr = 0.1
const currentPrice = 140
const multiplier = 1.5
const minPercent = 0.25
const maxPercent = 0.9
const distance = calculateAtrBasedTrailingDistance(
atr, currentPrice, multiplier, minPercent, maxPercent
)
// 0.1 / 140 * 100 = 0.071% * 1.5 = 0.107% < min 0.25%
expect(distance).toBe(minPercent)
})
it('should clamp trailing distance to maximum', () => {
// Very high ATR should clamp to max
const atr = 2.0
const currentPrice = 140
const multiplier = 1.5
const minPercent = 0.25
const maxPercent = 0.9
const distance = calculateAtrBasedTrailingDistance(
atr, currentPrice, multiplier, minPercent, maxPercent
)
// 2.0 / 140 * 100 = 1.43% * 1.5 = 2.14% > max 0.9%
expect(distance).toBe(maxPercent)
})
})
describe('Peak price tracking', () => {
it('LONG: should update peak price when price increases', () => {
const trade = createTradeAfterTP2('long', { peakPrice: 142.41 })
const newHighPrice = 143.00
// Simulate peak price update logic
if (newHighPrice > trade.peakPrice) {
trade.peakPrice = newHighPrice
}
expect(trade.peakPrice).toBe(143.00)
})
it('LONG: should NOT update peak price when price decreases', () => {
const trade = createTradeAfterTP2('long', { peakPrice: 142.41 })
const lowerPrice = 142.00
// Simulate peak price update logic
if (lowerPrice > trade.peakPrice) {
trade.peakPrice = lowerPrice
}
expect(trade.peakPrice).toBe(142.41) // Unchanged
})
it('SHORT: should update peak price when price decreases', () => {
const trade = createTradeAfterTP2('short', { peakPrice: 137.59 })
const newLowPrice = 137.00
// Simulate peak price update logic (for short, lower is better)
if (newLowPrice < trade.peakPrice) {
trade.peakPrice = newLowPrice
}
expect(trade.peakPrice).toBe(137.00)
})
it('SHORT: should NOT update peak price when price increases', () => {
const trade = createTradeAfterTP2('short', { peakPrice: 137.59 })
const higherPrice = 138.00
// Simulate peak price update logic
if (higherPrice < trade.peakPrice) {
trade.peakPrice = higherPrice
}
expect(trade.peakPrice).toBe(137.59) // Unchanged
})
})
describe('Trailing stop trigger', () => {
it('LONG: should trigger when price falls below trailing SL', () => {
const peakPrice = 143.00
const trailingPercent = 0.46 // ~0.46% trail
const trailingSL = calculateTrailingStopPrice(peakPrice, trailingPercent, 'long')
// Trail SL = 143 * (1 - 0.0046) = 142.34
expect(trailingSL).toBeCloseTo(142.34, 1)
// Price above trail should not trigger
expect(shouldStopLoss(142.50, { direction: 'long', stopLossPrice: trailingSL })).toBe(false)
// Price at trail should trigger
expect(shouldStopLoss(trailingSL, { direction: 'long', stopLossPrice: trailingSL })).toBe(true)
// Price below trail should trigger
expect(shouldStopLoss(142.00, { direction: 'long', stopLossPrice: trailingSL })).toBe(true)
})
it('SHORT: should trigger when price rises above trailing SL', () => {
const peakPrice = 137.00
const trailingPercent = 0.46
const trailingSL = calculateTrailingStopPrice(peakPrice, trailingPercent, 'short')
// Trail SL = 137 * (1 + 0.0046) = 137.63
expect(trailingSL).toBeCloseTo(137.63, 1)
// Price below trail should not trigger
expect(shouldStopLoss(137.30, { direction: 'short', stopLossPrice: trailingSL })).toBe(false)
// Price at trail should trigger
expect(shouldStopLoss(trailingSL, { direction: 'short', stopLossPrice: trailingSL })).toBe(true)
// Price above trail should trigger
expect(shouldStopLoss(138.00, { direction: 'short', stopLossPrice: trailingSL })).toBe(true)
})
it('LONG: trailing SL should move up but never down', () => {
let currentTrailingSL = 142.00
const trailingPercent = 0.46
// Price rises to 143, new trail = 142.34
const newTrailingSL1 = calculateTrailingStopPrice(143.00, trailingPercent, 'long')
if (newTrailingSL1 > currentTrailingSL) {
currentTrailingSL = newTrailingSL1
}
expect(currentTrailingSL).toBeCloseTo(142.34, 1)
// Price drops to 142.50, trail should NOT move down
const newTrailingSL2 = calculateTrailingStopPrice(142.50, trailingPercent, 'long')
if (newTrailingSL2 > currentTrailingSL) {
currentTrailingSL = newTrailingSL2
}
expect(currentTrailingSL).toBeCloseTo(142.34, 1) // Unchanged
// Price rises to 144, trail should move up
const newTrailingSL3 = calculateTrailingStopPrice(144.00, trailingPercent, 'long')
if (newTrailingSL3 > currentTrailingSL) {
currentTrailingSL = newTrailingSL3
}
expect(currentTrailingSL).toBeCloseTo(143.34, 1) // Moved up
})
it('SHORT: trailing SL should move down but never up', () => {
let currentTrailingSL = 138.00
const trailingPercent = 0.46
// Price drops to 137, new trail = 137.63
const newTrailingSL1 = calculateTrailingStopPrice(137.00, trailingPercent, 'short')
if (newTrailingSL1 < currentTrailingSL) {
currentTrailingSL = newTrailingSL1
}
expect(currentTrailingSL).toBeCloseTo(137.63, 1)
// Price rises to 137.50, trail should NOT move up
const newTrailingSL2 = calculateTrailingStopPrice(137.50, trailingPercent, 'short')
if (newTrailingSL2 < currentTrailingSL) {
currentTrailingSL = newTrailingSL2
}
expect(currentTrailingSL).toBeCloseTo(137.63, 1) // Unchanged
// Price drops to 136, trail should move down
const newTrailingSL3 = calculateTrailingStopPrice(136.00, trailingPercent, 'short')
if (newTrailingSL3 < currentTrailingSL) {
currentTrailingSL = newTrailingSL3
}
expect(currentTrailingSL).toBeCloseTo(136.63, 1) // Moved down
})
})
})

111
tests/setup.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Jest Global Test Setup
*
* Configures the test environment for Position Manager integration tests.
* Mocks external dependencies to isolate unit testing.
*/
// Extend Jest matchers if needed
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling
if (pass) {
return {
message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true,
}
} else {
return {
message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false,
}
}
},
})
// Declare custom matchers for TypeScript
declare global {
namespace jest {
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R
}
}
}
// Mock logger to reduce noise during tests
// Logger is mocked to:
// 1. Prevent console output during test runs
// 2. Isolate tests from external I/O operations
// 3. Enable verification of logging calls if needed
jest.mock('../lib/utils/logger', () => ({
logger: {
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
}))
// Mock Drift service to avoid network calls
jest.mock('../lib/drift/client', () => ({
getDriftService: jest.fn(() => ({
getPosition: jest.fn(),
getOraclePrice: jest.fn(),
isInitialized: true,
})),
initializeDriftService: jest.fn(),
}))
// Mock Pyth price monitor
jest.mock('../lib/pyth/price-monitor', () => ({
getPythPriceMonitor: jest.fn(() => ({
start: jest.fn(),
stop: jest.fn(),
getCachedPrice: jest.fn(),
getLatestPrice: jest.fn(),
})),
}))
// Mock database operations
jest.mock('../lib/database/trades', () => ({
getOpenTrades: jest.fn(() => Promise.resolve([])),
updateTradeExit: jest.fn(() => Promise.resolve()),
updateTradeState: jest.fn(() => Promise.resolve()),
createTrade: jest.fn(() => Promise.resolve()),
}))
// Mock Telegram notifications
jest.mock('../lib/notifications/telegram', () => ({
sendPositionClosedNotification: jest.fn(() => Promise.resolve()),
sendPositionOpenedNotification: jest.fn(() => Promise.resolve()),
}))
// Mock Drift orders
jest.mock('../lib/drift/orders', () => ({
closePosition: jest.fn(() => Promise.resolve({ success: true, realizedPnL: 0 })),
cancelAllOrders: jest.fn(() => Promise.resolve({ success: true, cancelledCount: 0 })),
placeExitOrders: jest.fn(() => Promise.resolve({ success: true })),
}))
// Mock market data cache
jest.mock('../lib/trading/market-data-cache', () => ({
getMarketDataCache: jest.fn(() => ({
get: jest.fn(() => null),
set: jest.fn(),
})),
}))
// Mock stop hunt tracker
jest.mock('../lib/trading/stop-hunt-tracker', () => ({
getStopHuntTracker: jest.fn(() => ({
recordStopHunt: jest.fn(() => Promise.resolve()),
updateRevengeOutcome: jest.fn(() => Promise.resolve()),
})),
}))
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks()
})
export {}