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.
503 lines
14 KiB
Markdown
503 lines
14 KiB
Markdown
# 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
|