From 36ba3809a14d00fcafb5162d345738a56c0b8152 Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Fri, 7 Nov 2025 15:10:01 +0100 Subject: [PATCH] Fix runner system by checking minimum position size viability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/drift/orders.ts | 27 +++++++----- lib/trading/position-manager.ts | 31 ++++++++++++- lib/trading/runner-calculator.ts | 74 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 lib/trading/runner-calculator.ts diff --git a/lib/drift/orders.ts b/lib/drift/orders.ts index ccd09e8..e0c739d 100644 --- a/lib/drift/orders.ts +++ b/lib/drift/orders.ts @@ -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) { diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index e65a447..222b14f 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -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 diff --git a/lib/trading/runner-calculator.ts b/lib/trading/runner-calculator.ts new file mode 100644 index 0000000..7b52464 --- /dev/null +++ b/lib/trading/runner-calculator.ts @@ -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))) +}