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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
74
lib/trading/runner-calculator.ts
Normal file
74
lib/trading/runner-calculator.ts
Normal 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user