Comprehensive guide covering: - How ATR is captured and stored (entry value frozen) - Static ATR approach (Phases 1-3): Use entry ATR for entire trade - Dynamic ATR approach (Phase 5+): Real-time updates via TradingView or bot calculation - Use cases: Dynamic TP/SL, trailing stops, scaling in/out decisions - Implementation path: Start simple with entry ATR, add real-time later if data supports - Code examples for all approaches - Troubleshooting common ATR issues - Database schema considerations Explains why waiting for data is critical before implementing advanced ATR features.
14 KiB
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
// 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
// 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)
// 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)
// 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+)
// 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:
//@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:
{
"type": "POSITION_UPDATE",
"symbol": "SOLUSDT",
"atr": 2.35, // Current ATR (updated)
"price": 188.50,
"direction": "long"
}
New API endpoint:
// 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:
// 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-updateendpoint - Add
currentATRfield toActiveTradeinterface - 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:
// 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
atrAtEntryfrom TradingView signals (already implemented) - Use static ATR for all calculations during trade
- Validate that scaling strategies work with entry ATR
Configuration:
// 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:
-- 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:
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:
- Check TradingView alert message: Should include
ATR:{{plot_0}}or similar - Check n8n "Parse Signal Enhanced" node: Should extract
atrfield - Verify webhook payload in n8n execution log
- 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:
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:
// 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
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)
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
- Entry ATR is sufficient for Phases 1-3 - Don't overcomplicate early
- Real-time ATR updates are optional - Only add if data proves benefit
- Test with data - Run analysis queries to validate ATR effectiveness
- 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 overviewdocs/guides/TESTING.md- How to test ATR-based featuresconfig/trading.ts- ATR configuration options