feat: refresh exit orders after TP1 and add dry-run harness

This commit is contained in:
mindesbunister
2025-11-05 10:00:39 +01:00
parent cbb6592153
commit 18e3e73e83
4 changed files with 383 additions and 64 deletions

View File

@@ -333,12 +333,7 @@ export class PositionManager {
trade.tp1Hit = true
trade.currentSize = positionSizeUSD
// Move SL to breakeven after TP1
trade.stopLossPrice = trade.entryPrice
trade.slMovedToBreakeven = true
console.log(`🛡️ Stop loss moved to breakeven: $${trade.stopLossPrice.toFixed(4)}`)
await this.saveTradeState(trade)
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 detection')
} else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) {
// TP2 fired (total should be ~95% closed, 5% runner left)
@@ -350,15 +345,15 @@ export class PositionManager {
await this.saveTradeState(trade)
// CRITICAL: Don't return early! Continue monitoring the runner position
// The trailing stop logic at line 732 needs to run
} else {
// Partial fill detected but unclear which TP - just update size
console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`)
trade.currentSize = positionSizeUSD
await this.saveTradeState(trade)
}
// Continue monitoring the remaining position
return
}
// CRITICAL: Check for entry price mismatch (NEW position opened)
@@ -380,10 +375,10 @@ export class PositionManager {
trade.lastPrice,
trade.direction
)
const accountPnLPercent = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * profitPercent) / 100
const accountPnL = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * accountPnL) / 100
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnL.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
try {
await updateTradeExit({
@@ -450,7 +445,8 @@ export class PositionManager {
currentPrice,
trade.direction
)
realizedPnL = (sizeForPnL * profitPercent) / 100
const accountPnL = profitPercent * trade.leverage
realizedPnL = (sizeForPnL * accountPnL) / 100
}
// Determine exit reason from trade state and P&L
@@ -632,56 +628,7 @@ export class PositionManager {
// Move SL based on breakEvenTriggerPercent setting
trade.tp1Hit = true
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100)
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.breakEvenTriggerPercent, // Use configured breakeven level
trade.direction
)
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
// CRITICAL: Cancel old on-chain SL orders and place new ones at updated price
try {
console.log('🗑️ Cancelling old stop loss orders...')
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`)
// Place new SL orders at breakeven/profit level for remaining position
console.log(`🛡️ Placing new SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
const exitOrdersResult = await placeExitOrders({
symbol: trade.symbol,
positionSizeUSD: trade.currentSize,
entryPrice: trade.entryPrice,
tp1Price: trade.tp2Price, // Only TP2 remains
tp2Price: trade.tp2Price, // Dummy, won't be used
stopLossPrice: newStopLossPrice,
tp1SizePercent: 100, // Close remaining 25% at TP2
tp2SizePercent: 0,
direction: trade.direction,
useDualStops: this.config.useDualStops,
softStopPrice: trade.direction === 'long'
? newStopLossPrice * 1.005 // 0.5% above for long
: newStopLossPrice * 0.995, // 0.5% below for short
hardStopPrice: newStopLossPrice,
})
if (exitOrdersResult.success) {
console.log('✅ New SL orders placed on-chain at updated price')
} else {
console.error('❌ Failed to place new SL orders:', exitOrdersResult.error)
}
}
} catch (error) {
console.error('❌ Failed to update on-chain SL orders:', error)
// Don't fail the TP1 exit if SL update fails - software monitoring will handle it
}
// Save state after TP1
await this.saveTradeState(trade)
await this.handlePostTp1Adjustments(trade, 'software TP1 execution')
return
}
@@ -707,7 +654,7 @@ export class PositionManager {
}
// 5. Take profit 2 (remaining position)
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
if (trade.tp1Hit && this.shouldTakeProfit2(currentPrice, trade)) {
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
// Calculate how much to close based on TP2 size percent
@@ -926,6 +873,103 @@ export class PositionManager {
console.log('✅ All positions closed')
}
private async handlePostTp1Adjustments(trade: ActiveTrade, context: string): Promise<void> {
if (trade.currentSize <= 0) {
console.log(`⚠️ Skipping TP1 adjustments for ${trade.symbol} (${context}) because current size is $${trade.currentSize.toFixed(2)}`)
await this.saveTradeState(trade)
return
}
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.breakEvenTriggerPercent,
trade.direction
)
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
console.log(`🔒 (${context}) SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
await this.refreshExitOrders(trade, {
stopLossPrice: newStopLossPrice,
tp1Price: trade.tp2Price,
tp1SizePercent: 100,
tp2Price: trade.tp2Price,
tp2SizePercent: 0,
context,
})
await this.saveTradeState(trade)
}
private async refreshExitOrders(
trade: ActiveTrade,
options: {
stopLossPrice: number
tp1Price: number
tp1SizePercent: number
tp2Price?: number
tp2SizePercent?: number
context: string
}
): Promise<void> {
if (trade.currentSize <= 0) {
console.log(`⚠️ Skipping exit order refresh for ${trade.symbol} (${options.context}) because tracked size is zero`)
return
}
try {
console.log(`🗑️ (${options.context}) Cancelling existing exit orders before refresh...`)
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
console.log(`✅ (${options.context}) Cancelled ${cancelResult.cancelledCount || 0} old orders`)
} else {
console.warn(`⚠️ (${options.context}) Failed to cancel old orders: ${cancelResult.error}`)
}
const tp2Price = options.tp2Price ?? options.tp1Price
const tp2SizePercent = options.tp2SizePercent ?? 0
const refreshParams: any = {
symbol: trade.symbol,
positionSizeUSD: trade.currentSize,
entryPrice: trade.entryPrice,
tp1Price: options.tp1Price,
tp2Price,
stopLossPrice: options.stopLossPrice,
tp1SizePercent: options.tp1SizePercent,
tp2SizePercent,
direction: trade.direction,
useDualStops: this.config.useDualStops,
}
if (this.config.useDualStops) {
const softStopBuffer = this.config.softStopBuffer ?? 0.4
const softStopPrice = trade.direction === 'long'
? options.stopLossPrice * (1 + softStopBuffer / 100)
: options.stopLossPrice * (1 - softStopBuffer / 100)
refreshParams.softStopPrice = softStopPrice
refreshParams.softStopBuffer = softStopBuffer
refreshParams.hardStopPrice = options.stopLossPrice
}
console.log(`🛡️ (${options.context}) Placing refreshed exit orders: size=$${trade.currentSize.toFixed(2)} SL=${options.stopLossPrice.toFixed(4)} TP=${options.tp1Price.toFixed(4)}`)
const exitOrdersResult = await placeExitOrders(refreshParams)
if (exitOrdersResult.success) {
console.log(`✅ (${options.context}) Exit orders refreshed on-chain`)
} else {
console.error(`❌ (${options.context}) Failed to place refreshed exit orders: ${exitOrdersResult.error}`)
}
} catch (error) {
console.error(`❌ (${options.context}) Error refreshing exit orders:`, error)
// Monitoring loop will still enforce SL logic even if on-chain refresh fails
}
}
/**
* Save trade state to database (for persistence across restarts)
*/

171
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.0"
},
"engines": {
@@ -188,6 +189,30 @@
"@solana/web3.js": "^1.68.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@drift-labs/sdk": {
"version": "2.143.0",
"resolved": "https://registry.npmjs.org/@drift-labs/sdk/-/sdk-2.143.0.tgz",
@@ -4208,6 +4233,34 @@
"node": ">=20.18.0"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/bn.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz",
@@ -4289,6 +4342,32 @@
"@types/node": "*"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
@@ -4947,6 +5026,13 @@
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
@@ -5116,6 +5202,16 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -6462,6 +6558,13 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -8003,6 +8106,57 @@
"integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==",
"license": "MIT"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -8112,6 +8266,13 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -8268,6 +8429,16 @@
"node": ">=12"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz",

View File

@@ -29,6 +29,7 @@
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.0"
},
"engines": {

103
scripts/dry-run-tp1-test.js Normal file
View File

@@ -0,0 +1,103 @@
process.env.TS_NODE_COMPILER_OPTIONS = JSON.stringify({
module: 'CommonJS',
moduleResolution: 'node',
esModuleInterop: true,
allowSyntheticDefaultImports: true,
})
const Module = require('module')
const originalRequire = Module.prototype.require
Module.prototype.require = function mockHelius(moduleId, ...args) {
if (moduleId === 'helius-laserstream' || moduleId.startsWith('helius-laserstream/')) {
console.log('⚠️ Mocking helius-laserstream module for dry run')
return {}
}
return originalRequire.apply(this, [moduleId, ...args])
}
require('ts-node/register')
const { Keypair } = require('@solana/web3.js')
const tradesModule = require('../lib/database/trades')
// Stub Prisma interactions for dry run so we don't need a database
tradesModule.getOpenTrades = async () => {
console.log(' Mock getOpenTrades returning empty list for dry run')
return []
}
tradesModule.updateTradeState = async () => {
console.log(' Mock updateTradeState no-op')
}
const { getInitializedPositionManager } = require('../lib/trading/position-manager')
const driftOrders = require('../lib/drift/orders')
// Stub Drift order helpers so we avoid needing an on-chain connection
driftOrders.cancelAllOrders = async (symbol) => {
console.log(` Mock cancelAllOrders invoked for ${symbol}`)
return { success: true, cancelledCount: 0 }
}
driftOrders.placeExitOrders = async (options) => {
console.log(' Mock placeExitOrders called with:', options)
return { success: true, signatures: ['MOCK_TP', 'MOCK_SL'] }
}
async function main() {
process.env.DRY_RUN = 'true'
process.env.DRIFT_ENV = 'devnet'
process.env.SOLANA_RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.devnet.solana.com'
const kp = Keypair.generate()
process.env.DRIFT_WALLET_PRIVATE_KEY = JSON.stringify(Array.from(kp.secretKey))
console.log('🧪 Starting TP1 refresh dry-run test')
const manager = await getInitializedPositionManager()
const entryPrice = 150
const trade = {
id: `dry-run-${Date.now()}`,
positionId: 'DRY_RUN_POSITION',
symbol: 'SOL-PERP',
direction: 'long',
entryPrice,
entryTime: Date.now(),
positionSize: 200,
leverage: 10,
stopLossPrice: entryPrice * 0.985,
tp1Price: entryPrice * 1.004,
tp2Price: entryPrice * 1.007,
emergencyStopPrice: entryPrice * 0.98,
currentSize: 200 * 0.25,
tp1Hit: true,
tp2Hit: false,
slMovedToBreakeven: false,
slMovedToProfit: false,
trailingStopActive: false,
realizedPnL: 10,
unrealizedPnL: 0,
peakPnL: 12,
peakPrice: entryPrice * 1.01,
maxFavorableExcursion: 1.2,
maxAdverseExcursion: -0.6,
maxFavorablePrice: entryPrice * 1.01,
maxAdversePrice: entryPrice * 0.99,
priceCheckCount: 0,
lastPrice: entryPrice,
lastUpdateTime: Date.now(),
}
await manager.addTrade(trade)
console.log('➡️ Invoking handlePostTp1Adjustments...')
await manager.handlePostTp1Adjustments(trade, 'dry-run script')
console.log('✅ Dry run complete. Review logs above for refreshed exit order placement.')
process.exit(0)
}
main().catch((err) => {
console.error('❌ Dry run test failed:', err)
process.exit(1)
})