Merge remote-tracking branch 'github/master'
This commit is contained in:
142
.github/copilot-instructions.md
vendored
142
.github/copilot-instructions.md
vendored
@@ -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 ⚠️
|
## ⚠️ CRITICAL: VERIFICATION MANDATE - READ THIS FIRST ⚠️
|
||||||
|
|
||||||
**THIS IS A REAL MONEY TRADING SYSTEM - EVERY CHANGE AFFECTS USER'S FINANCIAL FUTURE**
|
**THIS IS A REAL MONEY TRADING SYSTEM - EVERY CHANGE AFFECTS USER'S FINANCIAL FUTURE**
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -31,12 +31,6 @@ logs/
|
|||||||
# Docker
|
# Docker
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
# Test files
|
|
||||||
*.test.ts
|
|
||||||
*.test.js
|
|
||||||
test-*.ts
|
|
||||||
test-*.js
|
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
@@ -45,3 +39,6 @@ temp/
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
dist/
|
dist/
|
||||||
.backtester/
|
.backtester/
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage/
|
||||||
|
|||||||
28
jest.config.js
Normal file
28
jest.config.js
Normal 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
3580
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -7,7 +7,11 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"@drift-labs/sdk": "^2.75.0",
|
"@drift-labs/sdk": "^2.75.0",
|
||||||
@@ -30,9 +34,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bn.js": "^5.2.0",
|
"@types/bn.js": "^5.2.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^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",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
224
tests/README.md
Normal file
224
tests/README.md
Normal 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
|
||||||
|
```
|
||||||
247
tests/helpers/trade-factory.ts
Normal file
247
tests/helpers/trade-factory.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
200
tests/integration/position-manager/adx-runner-sl.test.ts
Normal file
200
tests/integration/position-manager/adx-runner-sl.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
155
tests/integration/position-manager/breakeven-sl.test.ts
Normal file
155
tests/integration/position-manager/breakeven-sl.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
308
tests/integration/position-manager/decision-helpers.test.ts
Normal file
308
tests/integration/position-manager/decision-helpers.test.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
241
tests/integration/position-manager/edge-cases.test.ts
Normal file
241
tests/integration/position-manager/edge-cases.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
197
tests/integration/position-manager/price-verification.test.ts
Normal file
197
tests/integration/position-manager/price-verification.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
177
tests/integration/position-manager/tp1-detection.test.ts
Normal file
177
tests/integration/position-manager/tp1-detection.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
271
tests/integration/position-manager/trailing-stop.test.ts
Normal file
271
tests/integration/position-manager/trailing-stop.test.ts
Normal 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
111
tests/setup.ts
Normal 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 {}
|
||||||
Reference in New Issue
Block a user