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 { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getMergedConfig } from '@/config/trading'
|
import { getMergedConfig, TradingConfig } from '@/config/trading'
|
||||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||||
import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades'
|
import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades'
|
||||||
|
import { getPythPriceMonitor } from '@/lib/pyth/price-monitor'
|
||||||
|
|
||||||
export interface RiskCheckRequest {
|
export interface RiskCheckRequest {
|
||||||
symbol: string
|
symbol: string
|
||||||
@@ -29,6 +30,98 @@ export interface RiskCheckResponse {
|
|||||||
qualityReasons?: string[]
|
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>> {
|
export async function POST(request: NextRequest): Promise<NextResponse<RiskCheckResponse>> {
|
||||||
try {
|
try {
|
||||||
// Verify authorization
|
// Verify authorization
|
||||||
@@ -57,8 +150,33 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
|||||||
const existingPosition = existingTrades.find(trade => trade.symbol === body.symbol)
|
const existingPosition = existingTrades.find(trade => trade.symbol === body.symbol)
|
||||||
|
|
||||||
if (existingPosition) {
|
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) {
|
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)', {
|
console.log('🚫 Risk check BLOCKED: Duplicate position (same direction)', {
|
||||||
symbol: body.symbol,
|
symbol: body.symbol,
|
||||||
existingDirection: existingPosition.direction,
|
existingDirection: existingPosition.direction,
|
||||||
@@ -69,7 +187,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: 'Duplicate position',
|
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
|
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(
|
const sameDirectionPosition = existingTrades.find(
|
||||||
trade => trade.symbol === driftSymbol && trade.direction === body.direction
|
trade => trade.symbol === driftSymbol && trade.direction === body.direction
|
||||||
)
|
)
|
||||||
|
|
||||||
if (sameDirectionPosition) {
|
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}`)
|
console.log(`⛔ DUPLICATE POSITION BLOCKED: Already have ${body.direction} position on ${driftSymbol}`)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Duplicate position detected',
|
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 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
@@ -366,6 +427,10 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
maxAdverseExcursion: 0,
|
maxAdverseExcursion: 0,
|
||||||
maxFavorablePrice: entryPrice,
|
maxFavorablePrice: entryPrice,
|
||||||
maxAdversePrice: entryPrice,
|
maxAdversePrice: entryPrice,
|
||||||
|
// Position scaling tracking
|
||||||
|
originalAdx: body.adx, // Store for scaling validation
|
||||||
|
timesScaled: 0,
|
||||||
|
totalScaleAdded: 0,
|
||||||
priceCheckCount: 0,
|
priceCheckCount: 0,
|
||||||
lastPrice: entryPrice,
|
lastPrice: entryPrice,
|
||||||
lastUpdateTime: Date.now(),
|
lastUpdateTime: Date.now(),
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ export interface TradingConfig {
|
|||||||
trailingStopPercent: number // Trail by this % below peak
|
trailingStopPercent: number // Trail by this % below peak
|
||||||
trailingStopActivation: number // Activate when runner profits exceed this %
|
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
|
// DEX specific
|
||||||
priceCheckIntervalMs: number // How often to check prices
|
priceCheckIntervalMs: number // How often to check prices
|
||||||
slippageTolerance: number // Max acceptable slippage (%)
|
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
|
trailingStopPercent: 0.3, // Trail by 0.3% below peak price
|
||||||
trailingStopActivation: 0.5, // Activate trailing when runner is +0.5% in profit
|
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
|
// DEX settings
|
||||||
priceCheckIntervalMs: 2000, // Check every 2 seconds
|
priceCheckIntervalMs: 2000, // Check every 2 seconds
|
||||||
slippageTolerance: 1.0, // 1% max slippage on market orders
|
slippageTolerance: 1.0, // 1% max slippage on market orders
|
||||||
@@ -306,6 +324,27 @@ export function getConfigFromEnv(): Partial<TradingConfig> {
|
|||||||
trailingStopActivation: process.env.TRAILING_STOP_ACTIVATION
|
trailingStopActivation: process.env.TRAILING_STOP_ACTIVATION
|
||||||
? parseFloat(process.env.TRAILING_STOP_ACTIVATION)
|
? parseFloat(process.env.TRAILING_STOP_ACTIVATION)
|
||||||
: undefined,
|
: 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
|
maxDailyDrawdown: process.env.MAX_DAILY_DRAWDOWN
|
||||||
? parseFloat(process.env.MAX_DAILY_DRAWDOWN)
|
? parseFloat(process.env.MAX_DAILY_DRAWDOWN)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export interface ActiveTrade {
|
|||||||
maxFavorablePrice: number // Price at best profit
|
maxFavorablePrice: number // Price at best profit
|
||||||
maxAdversePrice: number // Price at worst loss
|
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
|
// Monitoring
|
||||||
priceCheckCount: number
|
priceCheckCount: number
|
||||||
lastPrice: number
|
lastPrice: number
|
||||||
|
|||||||
Reference in New Issue
Block a user