Fix runner system by checking minimum position size viability

PROBLEM: Runner never activated because Drift force-closes positions below
minimum size. TP2 would close 80% leaving 5% runner (~$105), but Drift
automatically closed the entire position.

SOLUTION:
1. Created runner-calculator.ts with canUseRunner() to check if remaining
   size would be above Drift minimums BEFORE executing TP2 close
2. If runner not viable: Skip TP2 close entirely, activate trailing stop
   on full 25% remaining (from TP1)
3. If runner viable: Execute TP2 as normal, activate trailing on 5%

Benefits:
- Runner system will now actually work for viable position sizes
- Positions that are too small won't try to force-close below minimums
- Better logs showing why runner did/didn't activate
- Trailing stop works on larger % if runner not viable (better R:R)

Example: $2100 position → $525 after TP1 → $105 runner = VIABLE
         $4 ETH position → $1 after TP1 → $0.20 runner = NOT VIABLE

Runner will trail with ATR-based dynamic % (0.25-0.9%) below peak price.
This commit is contained in:
mindesbunister
2025-11-07 15:10:01 +01:00
parent 309cad8108
commit 36ba3809a1
3 changed files with 120 additions and 12 deletions

View File

@@ -500,19 +500,24 @@ export async function closePosition(
}
// Calculate size to close
let sizeToClose = position.size * (params.percentToClose / 100)
const sizeToClose = position.size * (params.percentToClose / 100)
const remainingSize = position.size - sizeToClose
// CRITICAL FIX: If calculated size is below minimum, close 100% instead
// This prevents "runner" positions from being too small to close
if (sizeToClose < marketConfig.minOrderSize) {
console.log(`⚠️ Calculated close size ${sizeToClose.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`)
console.log(` Forcing 100% close to avoid Drift rejection`)
sizeToClose = position.size // Close entire position
// CRITICAL: Check if remaining position would be below Drift minimum
// If so, Drift will force-close the entire position anyway
// Better to detect this upfront and return fullyClosed=true
const willForceFullClose = remainingSize > 0 && remainingSize < marketConfig.minOrderSize
if (willForceFullClose && params.percentToClose < 100) {
console.log(`⚠️ WARNING: Remaining size ${remainingSize.toFixed(4)} would be below Drift minimum ${marketConfig.minOrderSize}`)
console.log(` Drift will force-close entire position. Proceeding with 100% close.`)
console.log(` 💡 TIP: Increase position size or decrease TP2 close % to enable runner`)
}
console.log(`📝 Close order details:`)
console.log(` Current position: ${position.size.toFixed(4)} ${position.side}`)
console.log(` Closing: ${params.percentToClose}% (${sizeToClose.toFixed(4)})`)
console.log(` Remaining after close: ${remainingSize.toFixed(4)}`)
console.log(` Entry price: $${position.entryPrice.toFixed(4)}`)
console.log(` Unrealized P&L: $${position.unrealizedPnL.toFixed(2)}`)
@@ -620,8 +625,8 @@ export async function closePosition(
// Check remaining position size after close
const updatedPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
const remainingSize = updatedPosition ? Math.abs(updatedPosition.size) : 0
const fullyClosed = !updatedPosition || remainingSize === 0
const actualRemainingSize = updatedPosition ? Math.abs(updatedPosition.size) : 0
const fullyClosed = !updatedPosition || actualRemainingSize === 0 || willForceFullClose
if (fullyClosed) {
console.log('🗑️ Position fully closed, cancelling remaining orders...')
@@ -631,7 +636,7 @@ export async function closePosition(
}
} else if (params.percentToClose === 100) {
console.log(
`⚠️ Requested 100% close but ${remainingSize.toFixed(4)} base remains on-chain`
`⚠️ Requested 100% close but ${actualRemainingSize.toFixed(4)} base remains on-chain`
)
}
@@ -642,7 +647,7 @@ export async function closePosition(
closedSize: sizeToClose,
realizedPnL,
fullyClosed,
remainingSize,
remainingSize: actualRemainingSize,
}
} catch (error) {

View File

@@ -9,6 +9,7 @@ import { closePosition } from '../drift/orders'
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
import { canUseRunner, getViableTP2Percent } from './runner-calculator'
export interface ActiveTrade {
id: string
@@ -707,9 +708,37 @@ export class PositionManager {
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
// Calculate how much to close based on TP2 size percent
// Check if runner would be viable with current position size
const runnerCheck = canUseRunner(
trade.symbol,
trade.currentSize,
currentPrice,
this.config.takeProfit1SizePercent,
this.config.takeProfit2SizePercent
)
if (!runnerCheck.viable) {
console.log(`⚠️ Runner not viable: ${runnerCheck.reason}`)
console.log(` Skipping TP2 close, will use trailing stop on full 25% remaining`)
// Mark TP2 as "hit" but don't close anything - activate trailing on full 25%
trade.tp2Hit = true
trade.trailingStopActive = true
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
console.log(
`🏃 Runner activated on full remaining position: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% | trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
)
await this.saveTradeState(trade)
return
}
// Runner is viable - proceed with TP2 close
const percentToClose = this.config.takeProfit2SizePercent
console.log(`✅ Runner viable: ${runnerCheck.runnerSizeBase.toFixed(4)} base (${runnerCheck.runnerSizeUSD.toFixed(2)} USD)`)
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
// If some position remains, mark TP2 as hit and activate trailing stop

View File

@@ -0,0 +1,74 @@
/**
* Calculate if runner system is viable for current position size
*
* Runner needs to be above Drift minimum order size after TP1 and TP2 close
*/
import { getMarketConfig } from '../../config/trading'
export function canUseRunner(
symbol: string,
positionSizeUSD: number,
currentPrice: number,
tp1Percent: number = 75,
tp2Percent: number = 80
): { viable: boolean; runnerSizeUSD: number; runnerSizeBase: number; reason?: string } {
const marketConfig = getMarketConfig(symbol)
// Calculate runner size in USD
const afterTp1 = positionSizeUSD * ((100 - tp1Percent) / 100) // 25% remaining
const runnerSizeUSD = afterTp1 * ((100 - tp2Percent) / 100) // 20% of that = 5% total
// Convert to base asset size
const runnerSizeBase = runnerSizeUSD / currentPrice
// Check if above minimum
const minRequired = marketConfig.minOrderSize
const viable = runnerSizeBase >= minRequired
if (!viable) {
return {
viable: false,
runnerSizeUSD,
runnerSizeBase,
reason: `Runner ${runnerSizeBase.toFixed(4)} < minimum ${minRequired} ${symbol.replace('-PERP', '')}`
}
}
return {
viable: true,
runnerSizeUSD,
runnerSizeBase
}
}
/**
* Calculate maximum TP2 close percent that leaves a viable runner
* Returns adjusted TP2% that ensures runner >= minimum size
*/
export function getViableTP2Percent(
symbol: string,
positionSizeUSD: number,
currentPrice: number,
tp1Percent: number = 75
): number {
const marketConfig = getMarketConfig(symbol)
const afterTp1SizeUSD = positionSizeUSD * ((100 - tp1Percent) / 100)
// Calculate minimum runner size we need in USD
const minRunnerBase = marketConfig.minOrderSize * 1.1 // Add 10% buffer
const minRunnerUSD = minRunnerBase * currentPrice
// Runner = afterTp1 * (100 - tp2%) / 100
// minRunnerUSD = afterTp1 * (100 - tp2%) / 100
// Solve for tp2%:
// (100 - tp2%) = (minRunnerUSD / afterTp1) * 100
// tp2% = 100 - ((minRunnerUSD / afterTp1) * 100)
const maxTP2Percent = 100 - ((minRunnerUSD / afterTp1SizeUSD) * 100)
// Clamp between 0-95% (never close more than 95% at TP2)
return Math.max(0, Math.min(95, Math.floor(maxTP2Percent)))
}