From 8a8d4a348c1e39dd804a0c2b50028170839812fd Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Mon, 3 Nov 2025 15:35:33 +0100 Subject: [PATCH] feat: Add position scaling for strong confirmation signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Feature: Position Scaling** Allows adding to existing profitable positions when high-quality signals confirm trend strength. **Configuration (config/trading.ts):** - enablePositionScaling: false (disabled by default - enable after testing) - minScaleQualityScore: 75 (higher bar than initial 60) - minProfitForScale: 0.4% (must be at/past TP1) - maxScaleMultiplier: 2.0 (max 200% of original size) - scaleSizePercent: 50% (add 50% of original position) - minAdxIncrease: 5 (ADX must strengthen) - maxPricePositionForScale: 70% (don't chase resistance) **Validation Logic (check-risk endpoint):** Same-direction signal triggers scaling check if enabled: 1. Quality score ≥75 (stronger than initial entry) 2. Position profitable ≥0.4% (at/past TP1) 3. ADX increased ≥5 points (trend strengthening) 4. Price position <70% (not near resistance) 5. Total size <2x original (risk management) 6. Returns 'allowed: true, reason: Position scaling' if all pass **Execution (execute endpoint):** - Opens additional position at scale size (50% of original) - Updates ActiveTrade: timesScaled, totalScaleAdded, currentSize - Tracks originalAdx from first entry for comparison - Returns 'action: scaled' with scale details **ActiveTrade Interface:** Added fields: - originalAdx?: number (for scaling validation) - timesScaled?: number (track scaling count) - totalScaleAdded?: number (total USD added) **Example Scenario:** 1. LONG SOL at $176 (quality: 45, ADX: 13.4) - weak but entered 2. Price hits $176.70 (+0.4%) - at TP1 3. New LONG signal (quality: 78, ADX: 19) - strong confirmation 4. Scaling validation: ✅ Quality 78 ✅ Profit +0.4% ✅ ADX +5.6 ✅ Price 68% 5. Adds 50% more position at $176.70 6. Total position: 150% of original size **Conservative Design:** - Disabled by default (requires manual enabling) - Only scales INTO profitable positions (never averaging down) - Requires significant quality improvement (75 vs 60) - Requires trend confirmation (ADX increase) - Hard cap at 2x original size - Won't chase near resistance levels **Next Steps:** 1. Enable in settings: ENABLE_POSITION_SCALING=true 2. Test with small positions first 3. Monitor data: do scaled positions outperform? 4. Adjust thresholds based on results **Safety:** - All existing duplicate prevention logic intact - Flip logic unchanged (still requires quality check) - Position Manager tracks scaling state - Can be toggled on/off without code changes --- app/api/trading/check-risk/route.ts | 126 +++++++++++++++++++++++++++- app/api/trading/execute/route.ts | 69 ++++++++++++++- config/trading.ts | 39 +++++++++ lib/trading/position-manager.ts | 5 ++ 4 files changed, 233 insertions(+), 6 deletions(-) diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index f4c22ca..666fb52 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -6,9 +6,10 @@ */ import { NextRequest, NextResponse } from 'next/server' -import { getMergedConfig } from '@/config/trading' -import { getInitializedPositionManager } from '@/lib/trading/position-manager' +import { getMergedConfig, TradingConfig } from '@/config/trading' +import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades' +import { getPythPriceMonitor } from '@/lib/pyth/price-monitor' export interface RiskCheckRequest { symbol: string @@ -29,6 +30,98 @@ export interface RiskCheckResponse { qualityReasons?: string[] } +/** + * Position Scaling Validation + * Determines if adding to an existing position is allowed + */ +function shouldAllowScaling( + existingTrade: ActiveTrade, + newSignal: RiskCheckRequest, + config: TradingConfig +): { allowed: boolean; reasons: string[]; qualityScore?: number; qualityReasons?: string[] } { + const reasons: string[] = [] + + // Check if we have context metrics + if (!newSignal.atr || !newSignal.adx || !newSignal.pricePosition) { + reasons.push('Missing signal metrics for scaling validation') + return { allowed: false, reasons } + } + + // 1. Calculate new signal quality score + const qualityScore = scoreSignalQuality({ + atr: newSignal.atr, + adx: newSignal.adx, + rsi: newSignal.rsi || 50, + volumeRatio: newSignal.volumeRatio || 1, + pricePosition: newSignal.pricePosition, + direction: newSignal.direction, + minScore: config.minScaleQualityScore, + }) + + // 2. Check quality score (higher bar than initial entry) + if (qualityScore.score < config.minScaleQualityScore) { + reasons.push(`Quality score too low: ${qualityScore.score} (need ${config.minScaleQualityScore}+)`) + return { allowed: false, reasons, qualityScore: qualityScore.score, qualityReasons: qualityScore.reasons } + } + + // 3. Check current position profitability + const priceMonitor = getPythPriceMonitor() + const latestPrice = priceMonitor.getCachedPrice(newSignal.symbol) + const currentPrice = latestPrice?.price + + if (!currentPrice) { + reasons.push('Unable to fetch current price') + return { allowed: false, reasons, qualityScore: qualityScore.score } + } + + const pnlPercent = existingTrade.direction === 'long' + ? ((currentPrice - existingTrade.entryPrice) / existingTrade.entryPrice) * 100 + : ((existingTrade.entryPrice - currentPrice) / existingTrade.entryPrice) * 100 + + if (pnlPercent < config.minProfitForScale) { + reasons.push(`Position not profitable enough: ${pnlPercent.toFixed(2)}% (need ${config.minProfitForScale}%+)`) + return { allowed: false, reasons, qualityScore: qualityScore.score } + } + + // 4. Check ADX trend strengthening + const originalAdx = existingTrade.originalAdx || 0 + const adxIncrease = newSignal.adx - originalAdx + + if (adxIncrease < config.minAdxIncrease) { + reasons.push(`ADX not strengthening enough: +${adxIncrease.toFixed(1)} (need +${config.minAdxIncrease})`) + return { allowed: false, reasons, qualityScore: qualityScore.score } + } + + // 5. Check price position (don't chase near resistance) + if (newSignal.pricePosition > config.maxPricePositionForScale) { + reasons.push(`Price too high in range: ${newSignal.pricePosition.toFixed(0)}% (max ${config.maxPricePositionForScale}%)`) + return { allowed: false, reasons, qualityScore: qualityScore.score } + } + + // 6. Check max position size (if already scaled) + const totalScaled = existingTrade.timesScaled || 0 + const currentMultiplier = 1 + (totalScaled * (config.scaleSizePercent / 100)) + const newMultiplier = currentMultiplier + (config.scaleSizePercent / 100) + + if (newMultiplier > config.maxScaleMultiplier) { + reasons.push(`Max position size reached: ${(currentMultiplier * 100).toFixed(0)}% (max ${(config.maxScaleMultiplier * 100).toFixed(0)}%)`) + return { allowed: false, reasons, qualityScore: qualityScore.score } + } + + // All checks passed! + reasons.push(`Quality: ${qualityScore.score}/100`) + reasons.push(`P&L: +${pnlPercent.toFixed(2)}%`) + reasons.push(`ADX increased: +${adxIncrease.toFixed(1)}`) + reasons.push(`Price position: ${newSignal.pricePosition.toFixed(0)}%`) + + return { + allowed: true, + reasons, + qualityScore: qualityScore.score, + qualityReasons: qualityScore.reasons + } +} + export async function POST(request: NextRequest): Promise> { try { // Verify authorization @@ -57,8 +150,33 @@ export async function POST(request: NextRequest): Promise trade.symbol === body.symbol) if (existingPosition) { - // Check if it's the SAME direction (duplicate - block it) + // SAME direction - check if position scaling is allowed if (existingPosition.direction === body.direction) { + // Position scaling feature + if (config.enablePositionScaling) { + const scalingCheck = shouldAllowScaling(existingPosition, body, config) + + if (scalingCheck.allowed) { + console.log('✅ Position scaling ALLOWED:', scalingCheck.reasons) + return NextResponse.json({ + allowed: true, + reason: 'Position scaling', + details: `Scaling into ${body.direction} position - ${scalingCheck.reasons.join(', ')}`, + qualityScore: scalingCheck.qualityScore, + qualityReasons: scalingCheck.qualityReasons, + }) + } else { + console.log('🚫 Position scaling BLOCKED:', scalingCheck.reasons) + return NextResponse.json({ + allowed: false, + reason: 'Scaling not allowed', + details: scalingCheck.reasons.join(', '), + qualityScore: scalingCheck.qualityScore, + }) + } + } + + // Scaling disabled - block duplicate position console.log('🚫 Risk check BLOCKED: Duplicate position (same direction)', { symbol: body.symbol, existingDirection: existingPosition.direction, @@ -69,7 +187,7 @@ export async function POST(request: NextRequest): Promise trade.symbol === driftSymbol && trade.direction !== body.direction ) - // SAFETY CHECK: Prevent multiple positions on same symbol + // Check for same direction position (scaling vs duplicate) const sameDirectionPosition = existingTrades.find( trade => trade.symbol === driftSymbol && trade.direction === body.direction ) if (sameDirectionPosition) { + // Position scaling enabled - scale into existing position + if (config.enablePositionScaling) { + console.log(`📈 POSITION SCALING: Adding to existing ${body.direction} position on ${driftSymbol}`) + + // Calculate scale size + const scaleSize = (positionSize * leverage) * (config.scaleSizePercent / 100) + + console.log(`💰 Scaling position:`) + console.log(` Original size: $${sameDirectionPosition.positionSize}`) + console.log(` Scale size: $${scaleSize} (${config.scaleSizePercent}% of original)`) + console.log(` Leverage: ${leverage}x`) + + // Open additional position + const scaleResult = await openPosition({ + symbol: driftSymbol, + direction: body.direction, + sizeUSD: scaleSize, + slippageTolerance: config.slippageTolerance, + }) + + if (!scaleResult.success) { + console.error('❌ Failed to scale position:', scaleResult.error) + return NextResponse.json( + { + success: false, + error: 'Position scaling failed', + message: scaleResult.error, + }, + { status: 500 } + ) + } + + console.log(`✅ Scaled into position at $${scaleResult.fillPrice?.toFixed(4)}`) + + // Update Position Manager tracking + const timesScaled = (sameDirectionPosition.timesScaled || 0) + 1 + const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + scaleSize + const newTotalSize = sameDirectionPosition.currentSize + (scaleResult.fillSize || 0) + + // Update the trade tracking (simplified - just update the active trade object) + sameDirectionPosition.timesScaled = timesScaled + sameDirectionPosition.totalScaleAdded = totalScaleAdded + sameDirectionPosition.currentSize = newTotalSize + + console.log(`📊 Position scaled: ${timesScaled}x total, $${totalScaleAdded.toFixed(2)} added`) + + return NextResponse.json({ + success: true, + action: 'scaled', + positionId: sameDirectionPosition.positionId, + symbol: driftSymbol, + direction: body.direction, + scalePrice: scaleResult.fillPrice, + scaleSize: scaleSize, + totalSize: newTotalSize, + timesScaled: timesScaled, + timestamp: new Date().toISOString(), + }) + } + + // Scaling disabled - block duplicate console.log(`⛔ DUPLICATE POSITION BLOCKED: Already have ${body.direction} position on ${driftSymbol}`) return NextResponse.json( { success: false, error: 'Duplicate position detected', - message: `Already have an active ${body.direction} position on ${driftSymbol}. Close it first.`, + message: `Already have an active ${body.direction} position on ${driftSymbol}. Enable position scaling in settings to add to this position.`, }, { status: 400 } ) @@ -366,6 +427,10 @@ export async function POST(request: NextRequest): Promise70% of range (near resistance) + // DEX settings priceCheckIntervalMs: 2000, // Check every 2 seconds slippageTolerance: 1.0, // 1% max slippage on market orders @@ -306,6 +324,27 @@ export function getConfigFromEnv(): Partial { trailingStopActivation: process.env.TRAILING_STOP_ACTIVATION ? parseFloat(process.env.TRAILING_STOP_ACTIVATION) : undefined, + enablePositionScaling: process.env.ENABLE_POSITION_SCALING + ? process.env.ENABLE_POSITION_SCALING === 'true' + : undefined, + minScaleQualityScore: process.env.MIN_SCALE_QUALITY_SCORE + ? parseInt(process.env.MIN_SCALE_QUALITY_SCORE) + : undefined, + minProfitForScale: process.env.MIN_PROFIT_FOR_SCALE + ? parseFloat(process.env.MIN_PROFIT_FOR_SCALE) + : undefined, + maxScaleMultiplier: process.env.MAX_SCALE_MULTIPLIER + ? parseFloat(process.env.MAX_SCALE_MULTIPLIER) + : undefined, + scaleSizePercent: process.env.SCALE_SIZE_PERCENT + ? parseFloat(process.env.SCALE_SIZE_PERCENT) + : undefined, + minAdxIncrease: process.env.MIN_ADX_INCREASE + ? parseFloat(process.env.MIN_ADX_INCREASE) + : undefined, + maxPricePositionForScale: process.env.MAX_PRICE_POSITION_FOR_SCALE + ? parseFloat(process.env.MAX_PRICE_POSITION_FOR_SCALE) + : undefined, maxDailyDrawdown: process.env.MAX_DAILY_DRAWDOWN ? parseFloat(process.env.MAX_DAILY_DRAWDOWN) : undefined, diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index be92f97..4fc38f0 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -48,6 +48,11 @@ export interface ActiveTrade { maxFavorablePrice: number // Price at best profit maxAdversePrice: number // Price at worst loss + // Position scaling tracking + originalAdx?: number // ADX at initial entry (for scaling validation) + timesScaled?: number // How many times position has been scaled + totalScaleAdded?: number // Total USD added through scaling + // Monitoring priceCheckCount: number lastPrice: number