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:
mindesbunister
2025-11-03 15:35:33 +01:00
parent 57f0457f95
commit 8a8d4a348c
4 changed files with 233 additions and 6 deletions

View File

@@ -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.`,
})
}