feat: Add position scaling for strong confirmation signals
**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
This commit is contained in:
@@ -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<NextResponse<RiskCheckResponse>> {
|
||||
try {
|
||||
// Verify authorization
|
||||
@@ -57,8 +150,33 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
||||
const existingPosition = existingTrades.find(trade => 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<NextResponse<RiskCheck
|
||||
return NextResponse.json({
|
||||
allowed: false,
|
||||
reason: 'Duplicate position',
|
||||
details: `Already have ${existingPosition.direction} position on ${body.symbol} (entry: $${existingPosition.entryPrice})`,
|
||||
details: `Already have ${existingPosition.direction} position on ${body.symbol} (entry: $${existingPosition.entryPrice}). Enable scaling in settings to add to position.`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -213,18 +213,79 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
trade => 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): Promise<NextResponse<ExecuteTr
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: entryPrice,
|
||||
maxAdversePrice: entryPrice,
|
||||
// Position scaling tracking
|
||||
originalAdx: body.adx, // Store for scaling validation
|
||||
timesScaled: 0,
|
||||
totalScaleAdded: 0,
|
||||
priceCheckCount: 0,
|
||||
lastPrice: entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
|
||||
@@ -41,6 +41,15 @@ export interface TradingConfig {
|
||||
trailingStopPercent: number // Trail by this % below peak
|
||||
trailingStopActivation: number // Activate when runner profits exceed this %
|
||||
|
||||
// Position Scaling (add to winning positions)
|
||||
enablePositionScaling: boolean // Allow scaling into existing positions
|
||||
minScaleQualityScore: number // Minimum quality score for scaling signal (0-100)
|
||||
minProfitForScale: number // Position must be this % profitable to scale
|
||||
maxScaleMultiplier: number // Max total position size (e.g., 2.0 = 200% of original)
|
||||
scaleSizePercent: number // Scale size as % of original position (e.g., 50)
|
||||
minAdxIncrease: number // ADX must increase by this much for scaling
|
||||
maxPricePositionForScale: number // Don't scale if price position above this %
|
||||
|
||||
// DEX specific
|
||||
priceCheckIntervalMs: number // How often to check prices
|
||||
slippageTolerance: number // Max acceptable slippage (%)
|
||||
@@ -109,6 +118,15 @@ export const DEFAULT_TRADING_CONFIG: TradingConfig = {
|
||||
trailingStopPercent: 0.3, // Trail by 0.3% below peak price
|
||||
trailingStopActivation: 0.5, // Activate trailing when runner is +0.5% in profit
|
||||
|
||||
// Position Scaling (conservative defaults)
|
||||
enablePositionScaling: false, // Disabled by default - enable after testing
|
||||
minScaleQualityScore: 75, // Only scale with strong signals (vs 60 for initial entry)
|
||||
minProfitForScale: 0.4, // Position must be at/past TP1 to scale
|
||||
maxScaleMultiplier: 2.0, // Max 2x original position size total
|
||||
scaleSizePercent: 50, // Scale with 50% of original position size
|
||||
minAdxIncrease: 5, // ADX must increase by 5+ points (trend strengthening)
|
||||
maxPricePositionForScale: 70, // Don't scale if price >70% 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<TradingConfig> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user