# ATR-Based Position Scaling Guide ## Overview This guide explains how the trading bot uses Average True Range (ATR) for position management decisions, including scaling in/out of positions, dynamic stop losses, and take profit targets. --- ## Current Architecture: Entry ATR Storage ### How ATR is Captured ``` TradingView Signal β†’ Contains ATR value (e.g., 2.15) ↓ Bot receives signal via n8n webhook ↓ Stores ATR in database: atrAtEntry = 2.15 ↓ Position Manager uses stored ATR for entire trade lifecycle ``` **Key Point:** ATR value is "frozen" at entry time and stored in the `Trade.atrAtEntry` field. ### Current Data Flow ```typescript // Entry signal from TradingView (via n8n) { "symbol": "SOLUSDT", "direction": "long", "atr": 2.15, // Sent once at entry "adx": 28, "rsi": 62, "volumeRatio": 1.3, "pricePosition": 45 } // Stored in database Trade { atrAtEntry: 2.15, entryPrice: 186.50, // ... other fields } // Used by Position Manager (every 2 seconds) const atr = trade.atrAtEntry || 2.0 // Fallback if missing ``` --- ## Approach 1: Static ATR (Entry Value) **Status:** Recommended for Phases 1-3 (Current Implementation) ### How It Works ```typescript // In position-manager.ts monitoring loop async checkTargets(trade: ActiveTrade, currentPrice: number) { // Use ATR from entry signal (static for entire trade) const atr = trade.atrAtEntry || 2.0 // Calculate bands using ENTRY ATR const directionMultiplier = trade.direction === 'long' ? 1 : -1 const band_05x = trade.entryPrice + (atr * 0.5 * directionMultiplier) const band_1x = trade.entryPrice + (atr * 1.0 * directionMultiplier) const band_15x = trade.entryPrice + (atr * 1.5 * directionMultiplier) const band_2x = trade.entryPrice + (atr * 2.0 * directionMultiplier) // Check current price against bands if (currentPrice >= band_1x && !trade.band1xCrossed) { console.log('🎯 Price crossed 1Γ—ATR band') trade.band1xCrossed = true // Trigger actions (e.g., adjust trailing stop) } } ``` ### Use Cases **1. Dynamic Take Profit Targets (Phase 2)** ```typescript // Instead of fixed +1.5% and +3.0% const tp1Price = trade.entryPrice + (atr * 1.5 * directionMultiplier) const tp2Price = trade.entryPrice + (atr * 3.0 * directionMultiplier) // If ATR = 2.0 and entry = $100: // TP1 = $103 (3% move) // TP2 = $106 (6% move) // If ATR = 0.5 and entry = $100: // TP1 = $100.75 (0.75% move) // TP2 = $101.50 (1.5% move) ``` **2. ATR-Based Trailing Stop (Phase 5)** ```typescript // Instead of fixed 0.3% trailing stop const trailingStopDistance = atr * 1.5 // Trail by 1.5Γ—ATR // Calculate trailing stop price const trailingStopPrice = trade.direction === 'long' ? trade.peakPrice - trailingStopDistance : trade.peakPrice + trailingStopDistance // If ATR = 2.0 (high volatility): // Trailing stop = 3% below peak (gives room to breathe) // If ATR = 0.5 (low volatility): // Trailing stop = 0.75% below peak (tighter protection) ``` **3. Scaling In Decisions (Phase 6+)** ```typescript // Scale in on healthy pullback (0.5Γ—ATR from peak) const scaleInTrigger = trade.direction === 'long' ? trade.peakPrice - (atr * 0.5) // Long: pullback from high : trade.peakPrice + (atr * 0.5) // Short: rally from low // Conditions for scaling in const qualityHigh = trade.signalQualityScore >= 80 const pullbackHealthy = trade.direction === 'long' ? currentPrice >= scaleInTrigger && currentPrice < trade.peakPrice : currentPrice <= scaleInTrigger && currentPrice > trade.peakPrice const notAlreadyScaled = !trade.hasScaledIn const withinRiskLimits = trade.positionSize * 1.5 <= maxPositionSize if (qualityHigh && pullbackHealthy && notAlreadyScaled && withinRiskLimits) { await scaleIntoPosition(trade, 0.5) // Add 50% more size } ``` ### Advantages - βœ… **Simple:** No additional infrastructure needed - βœ… **Consistent:** Uses same volatility context as entry decision - βœ… **No sync issues:** No need to track TradingView state - βœ… **Good for short-duration trades:** Entry ATR valid for 30min-2 hour timeframes ### Limitations - ❌ **Stale data:** ATR from entry may be outdated hours later - ❌ **No adaptation:** If volatility changes mid-trade, targets don't adjust - ❌ **Example:** Enter with ATR=2.0, but 3 hours later ATR drops to 0.8 - Bot still uses 2.0 for calculations - May give too much room to runner (3% trailing stop instead of 1.2%) --- ## Approach 2: Real-Time ATR Updates **Status:** Future enhancement (Phase 5+) ### Option A: TradingView Periodic Updates **Pine Script sends ATR updates while position is open:** ```pine //@version=5 strategy("ATR Monitor with Updates", overlay=true) // Your entry logic longSignal = yourLongCondition() shortSignal = yourShortCondition() if longSignal strategy.entry("Long", strategy.long) if shortSignal strategy.entry("Short", strategy.short) // Send ATR updates every candle close if position open atr = ta.atr(14) if strategy.position_size != 0 and barstate.isconfirmed message = "POSITION_UPDATE" + " | SYMBOL:" + syminfo.ticker + " | ATR:" + str.tostring(atr, "#.##") + " | PRICE:" + str.tostring(close, "#.####") + " | DIRECTION:" + (strategy.position_size > 0 ? "long" : "short") alert(message, alert.freq_once_per_bar_close) ``` **Bot receives updates:** ```json { "type": "POSITION_UPDATE", "symbol": "SOLUSDT", "atr": 2.35, // Current ATR (updated) "price": 188.50, "direction": "long" } ``` **New API endpoint:** ```typescript // app/api/trading/position-update/route.ts export async function POST(request: NextRequest) { const body = await request.json() // Find active trade by symbol and direction const positionManager = await getInitializedPositionManager() const trade = positionManager.findTradeBySymbol(body.symbol, body.direction) if (trade) { // Update current ATR trade.currentATR = body.atr // Recalculate bands with fresh ATR const band_1x = trade.entryPrice + (body.atr * 1.0) const band_2x = trade.entryPrice + (body.atr * 2.0) // Update trailing stop distance dynamically trade.trailingStopDistance = body.atr * 1.5 console.log(`πŸ“Š ATR updated: ${trade.atrAtEntry} β†’ ${body.atr}`) } return NextResponse.json({ success: true }) } ``` **Position Manager logic:** ```typescript // In position-manager.ts interface ActiveTrade { // ... existing fields atrAtEntry: number // Original ATR from entry currentATR?: number // Updated ATR (if receiving updates) trailingStopDistance?: number // Dynamic trailing stop } async checkTargets(trade: ActiveTrade, currentPrice: number) { // Use current ATR if available, fallback to entry ATR const atr = trade.currentATR || trade.atrAtEntry || 2.0 // Rest of logic uses current ATR const band_1x = trade.entryPrice + (atr * 1.0) // ... } ``` ### Advantages - βœ… **Always current:** Uses latest volatility data - βœ… **Adapts to market:** If volatility spikes, targets widen automatically - βœ… **More accurate:** Trailing stops adjust to current conditions ### Limitations - ❌ **Complex:** Requires new endpoint and TradingView webhook setup - ❌ **Webhook spam:** Sends updates every 5-15 minutes (candle close frequency) - ❌ **Sync issues:** TradingView doesn't know if bot actually has position open - If bot closes position, TradingView keeps sending updates - Need to handle "position not found" gracefully - ❌ **API rate limits:** More webhook calls to your server ### Implementation Checklist - [ ] Create `/api/trading/position-update` endpoint - [ ] Add `currentATR` field to `ActiveTrade` interface - [ ] Update Position Manager to use `currentATR || atrAtEntry` - [ ] Modify Pine Script to send periodic ATR updates - [ ] Add n8n workflow node to parse ATR updates - [ ] Test with position open/close sync - [ ] Add database field to track ATR history: `atrUpdates: Json[]` --- ### Option B: Bot Calculates ATR Itself **Bot fetches historical candles and calculates ATR:** ```typescript // lib/indicators/atr.ts export function calculateATR(candles: OHLC[], period: number = 14): number { if (candles.length < period) { throw new Error(`Need at least ${period} candles for ATR`) } const trueRanges: number[] = [] for (let i = 1; i < candles.length; i++) { const high = candles[i].high const low = candles[i].low const prevClose = candles[i - 1].close const tr = Math.max( high - low, // Current high-low Math.abs(high - prevClose), // Current high - previous close Math.abs(low - prevClose) // Current low - previous close ) trueRanges.push(tr) } // Simple Moving Average of True Range const atr = trueRanges.slice(-period).reduce((a, b) => a + b, 0) / period return atr } // In position-manager.ts async updateATR(trade: ActiveTrade) { // Fetch last 14 candles from price feed const candles = await fetchRecentCandles(trade.symbol, 14, '5m') const currentATR = calculateATR(candles) trade.currentATR = currentATR console.log(`πŸ“Š Calculated ATR: ${currentATR.toFixed(2)}`) } ``` ### Advantages - βœ… **Autonomous:** No TradingView dependency - βœ… **Always fresh:** Calculate on-demand - βœ… **No webhooks:** No additional API calls to your server ### Limitations - ❌ **Complex:** Need to implement ATR calculation in TypeScript - ❌ **Data source:** Need reliable OHLC candle data - Pyth Network: Primarily provides spot prices, may not have full OHLC - Drift SDK: May have orderbook data but not historical candles - Alternative: Fetch from Binance/CoinGecko API - ❌ **API calls:** Need to fetch candles every update cycle (rate limits) - ❌ **Performance:** Additional latency for fetching + calculating --- ## Recommended Implementation Path ### Phase 1-3: Use Entry ATR (Current) βœ… **What to do:** - Store `atrAtEntry` from TradingView signals (already implemented) - Use static ATR for all calculations during trade - Validate that scaling strategies work with entry ATR **Configuration:** ```typescript // config/trading.ts export interface TradingConfig { // ... existing config useATRTargets: boolean // Enable ATR-based TP1/TP2 atrMultiplierTP1: number // 1.5Γ—ATR for TP1 atrMultiplierTP2: number // 3.0Γ—ATR for TP2 atrMultiplierTrailing: number // 1.5Γ—ATR for trailing stop atrFallback: number // Default ATR if missing (2.0) } ``` ### Phase 4: Add ATR Normalization **Analyze collected data:** ```sql -- What's typical ATR range for SOL-PERP? SELECT ROUND(MIN("atrAtEntry")::numeric, 2) as min_atr, ROUND(AVG("atrAtEntry")::numeric, 2) as avg_atr, ROUND(MAX("atrAtEntry")::numeric, 2) as max_atr, ROUND(STDDEV("atrAtEntry")::numeric, 2) as stddev_atr FROM "Trade" WHERE "atrAtEntry" IS NOT NULL AND "atrAtEntry" > 0; -- Result example: -- min_atr: 0.5, avg_atr: 2.0, max_atr: 3.5, stddev: 0.8 ``` **Implement normalization:** ```typescript function normalizeATR(atr: number, baseline: number = 2.0): number { // Returns factor relative to baseline return atr / baseline } // Usage const atrFactor = normalizeATR(trade.atrAtEntry, 2.0) const tp1Price = trade.entryPrice + (baseline_tp1_percent * atrFactor) // If ATR = 3.0 (high volatility): // atrFactor = 1.5, TP1 = entry + (1.5% Γ— 1.5) = entry + 2.25% // If ATR = 1.0 (low volatility): // atrFactor = 0.5, TP1 = entry + (1.5% Γ— 0.5) = entry + 0.75% ``` ### Phase 5+: Consider Real-Time Updates **Decision gate:** - βœ… Do trades last > 2 hours frequently? - βœ… Does ATR change significantly during typical trade duration? - βœ… Would dynamic updates improve performance measurably? **If YES to all three:** - Implement Option A (TradingView updates) OR Option B (Bot calculates) - A/B test: 20 trades with real-time ATR vs 20 with entry ATR - Compare: Win rate, avg P&L, trailing stop effectiveness **If NO:** - Stay with entry ATR (simpler, good enough) --- ## Troubleshooting ### Problem: ATR values are 0 or missing **Cause:** TradingView not sending ATR or n8n not extracting it **Solution:** 1. Check TradingView alert message: Should include `ATR:{{plot_0}}` or similar 2. Check n8n "Parse Signal Enhanced" node: Should extract `atr` field 3. Verify webhook payload in n8n execution log 4. Ensure Pine Script has `atr = ta.atr(14)` and plots it ### Problem: ATR seems too high/low **Cause:** Using wrong timeframe or different calculation method **TradingView ATR calculation:** ```pine atr = ta.atr(14) // 14-period ATR ``` **Bot should use same period (14) if calculating itself.** **Typical ATR ranges for SOL-PERP:** - 5-minute chart: 0.3 - 1.5 (low to high volatility) - 15-minute chart: 0.8 - 3.0 (low to high volatility) - Daily chart: 3.0 - 10.0 (low to high volatility) ### Problem: Trailing stop too tight/loose with ATR **Cause:** Wrong ATR multiplier **Solution:** ```typescript // Test different multipliers const trailingStopDistance = atr * 1.5 // Start here // Too tight? Increase to 2.0 // Too loose? Decrease to 1.0 // Log and analyze console.log(`ATR: ${atr}, Trailing: ${trailingStopDistance} (${(trailingStopDistance/trade.entryPrice*100).toFixed(2)}%)`) ``` --- ## Database Schema ### Current Fields ```prisma model Trade { // ... other fields atrAtEntry Float? // ATR% when trade opened adxAtEntry Float? // ADX trend strength rsiAtEntry Float? // RSI momentum volumeAtEntry Float? // Volume relative to MA pricePositionAtEntry Float? // Price position in range } ``` ### Future Enhancement (Real-Time Updates) ```prisma model Trade { // ... existing fields atrHistory Json? // Array of ATR updates: [{time, atr, price}] } // Example atrHistory value: // [ // {"time": "2025-10-31T10:00:00Z", "atr": 2.15, "price": 186.50}, // {"time": "2025-10-31T10:15:00Z", "atr": 2.28, "price": 188.20}, // {"time": "2025-10-31T10:30:00Z", "atr": 2.45, "price": 189.10} // ] ``` --- ## Key Takeaways 1. **Entry ATR is sufficient for Phases 1-3** - Don't overcomplicate early 2. **Real-time ATR updates are optional** - Only add if data proves benefit 3. **Test with data** - Run analysis queries to validate ATR effectiveness 4. **Start simple, optimize later** - Use entry ATR β†’ Analyze results β†’ Then enhance **Most important:** Let the system collect data first. Implement ATR-based logic AFTER you have 20-50 trades with real ATR values to validate the approach! --- ## Related Documentation - `POSITION_SCALING_ROADMAP.md` - 6-phase optimization plan - `.github/copilot-instructions.md` - Architecture overview - `docs/guides/TESTING.md` - How to test ATR-based features - `config/trading.ts` - ATR configuration options