Database changes: - Added indicatorVersion field to Trade table - Added indicatorVersion field to BlockedSignal table - Tracks which Pine Script version (v5, v6, etc.) generated each signal Pine Script changes: - v6 now includes '| IND:v6' in alert messages - Enables differentiation between v5 and v6 signals in database Documentation: - Created INDICATOR_VERSION_TRACKING.md with full implementation guide - Includes n8n workflow update instructions - Includes SQL analysis queries for v5 vs v6 comparison - Includes rollback plan if needed Next steps (manual): 1. Update n8n workflow Parse Signal Enhanced node to extract IND field 2. Update n8n HTTP requests to pass indicatorVersion 3. Update API endpoints to accept and save indicatorVersion 4. Rebuild Docker container Benefits: - Compare v5 vs v6 Pine Script effectiveness - Track which version generated winning/losing trades - Validate that v6 price position filter reduces blocked signals - Data-driven decisions on Pine Script improvements
241 lines
12 KiB
Plaintext
241 lines
12 KiB
Plaintext
//@version=5
|
||
indicator("Bullmania Money Line v6 Improved", overlay=true)
|
||
|
||
// Calculation source (Chart vs Heikin Ashi)
|
||
srcMode = input.string("Chart", "Calculation source", options=["Chart","Heikin Ashi"], tooltip="Use regular chart candles or Heikin Ashi for the line calculation.")
|
||
|
||
// Parameter Mode
|
||
paramMode = input.string("Profiles by timeframe", "Parameter Mode", options=["Single", "Profiles by timeframe"], tooltip="Choose whether to use one global set of parameters or timeframe-specific profiles.")
|
||
|
||
// Single (global) parameters
|
||
atrPeriodSingle = input.int(10, "ATR Period (Single mode)", minval=1, group="Single Mode")
|
||
multiplierSingle = input.float(3.0, "Multiplier (Single mode)", minval=0.1, step=0.1, group="Single Mode")
|
||
|
||
// Profile override when using profiles
|
||
profileOverride = input.string("Auto", "Profile Override", options=["Auto", "Minutes", "Hours", "Daily", "Weekly/Monthly"], tooltip="When in 'Profiles by timeframe' mode, choose a fixed profile or let it auto-detect from the chart timeframe.", group="Profiles")
|
||
|
||
// Timeframe profile parameters
|
||
// Minutes (<= 59m)
|
||
atr_m = input.int(12, "ATR Period (Minutes)", minval=1, group="Profiles — Minutes")
|
||
mult_m = input.float(3.3, "Multiplier (Minutes)", minval=0.1, step=0.1, group="Profiles — Minutes")
|
||
|
||
// Hours (>=1h and <1d)
|
||
atr_h = input.int(10, "ATR Period (Hours)", minval=1, group="Profiles — Hours")
|
||
mult_h = input.float(3.0, "Multiplier (Hours)", minval=0.1, step=0.1, group="Profiles — Hours")
|
||
|
||
// Daily (>=1d and <1w)
|
||
atr_d = input.int(10, "ATR Period (Daily)", minval=1, group="Profiles — Daily")
|
||
mult_d = input.float(2.8, "Multiplier (Daily)", minval=0.1, step=0.1, group="Profiles — Daily")
|
||
|
||
// Weekly/Monthly (>=1w)
|
||
atr_w = input.int(7, "ATR Period (Weekly/Monthly)", minval=1, group="Profiles — Weekly/Monthly")
|
||
mult_w = input.float(2.5, "Multiplier (Weekly/Monthly)", minval=0.1, step=0.1, group="Profiles — Weekly/Monthly")
|
||
|
||
// Optional MACD confirmation
|
||
useMacd = input.bool(false, "Use MACD confirmation", inline="macd")
|
||
macdSrc = input.source(close, "MACD Source", inline="macd")
|
||
macdFastLen = input.int(12, "Fast", minval=1, inline="macdLens")
|
||
macdSlowLen = input.int(26, "Slow", minval=1, inline="macdLens")
|
||
macdSigLen = input.int(9, "Signal", minval=1, inline="macdLens")
|
||
|
||
// Entry filters (optional)
|
||
groupFilters = "Entry filters"
|
||
useEntryBuffer = input.bool(false, "Require entry buffer (ATR)", group=groupFilters, tooltip="If enabled, the close must be beyond the Money Line by the buffer amount to avoid wick flips.")
|
||
entryBufferATR = input.float(0.15, "Buffer size (in ATR)", minval=0.0, step=0.05, group=groupFilters, tooltip="0.10–0.20 works well on 1h.")
|
||
confirmBars = input.int(0, "Bars to confirm after flip", minval=0, maxval=2, group=groupFilters, tooltip="0 = signal on flip bar. 1 = wait one bar.")
|
||
useAdx = input.bool(false, "Use ADX trend-strength filter", group=groupFilters, tooltip="If enabled, require ADX to be above a threshold to reduce chop.")
|
||
adxLen = input.int(14, "ADX Length", minval=1, group=groupFilters)
|
||
adxMin = input.int(20, "ADX minimum", minval=0, maxval=100, group=groupFilters)
|
||
|
||
// NEW v6 FILTERS
|
||
groupV6Filters = "v6 Quality Filters"
|
||
usePricePosition = input.bool(true, "Use price position filter", group=groupV6Filters, tooltip="Prevent chasing extremes - don't buy at top of range or sell at bottom.")
|
||
longPosMax = input.float(85, "Long max position %", minval=0, maxval=100, group=groupV6Filters, tooltip="Don't buy if price is above this % of 100-bar range (prevents chasing highs).")
|
||
shortPosMin = input.float(15, "Short min position %", minval=0, maxval=100, group=groupV6Filters, tooltip="Don't sell if price is below this % of 100-bar range (prevents chasing lows).")
|
||
|
||
useVolumeFilter = input.bool(false, "Use volume filter", group=groupV6Filters, tooltip="Filter signals with extreme volume (too low = dead, too high = climax).")
|
||
volMin = input.float(0.7, "Volume min ratio", minval=0.1, step=0.1, group=groupV6Filters, tooltip="Minimum volume relative to 20-bar MA.")
|
||
volMax = input.float(3.0, "Volume max ratio", minval=0.5, step=0.5, group=groupV6Filters, tooltip="Maximum volume relative to 20-bar MA.")
|
||
|
||
useRsiFilter = input.bool(false, "Use RSI momentum filter", group=groupV6Filters, tooltip="Ensure momentum confirms direction.")
|
||
rsiLongMin = input.float(45, "RSI long minimum", minval=0, maxval=100, group=groupV6Filters)
|
||
rsiLongMax = input.float(70, "RSI long maximum", minval=0, maxval=100, group=groupV6Filters)
|
||
rsiShortMin = input.float(30, "RSI short minimum", minval=0, maxval=100, group=groupV6Filters)
|
||
rsiShortMax = input.float(55, "RSI short maximum", minval=0, maxval=100, group=groupV6Filters)
|
||
|
||
// Determine effective parameters based on selected mode/profile
|
||
var string activeProfile = ""
|
||
resSec = timeframe.in_seconds(timeframe.period)
|
||
isMinutes = resSec < 3600
|
||
isHours = resSec >= 3600 and resSec < 86400
|
||
isDaily = resSec >= 86400 and resSec < 604800
|
||
isWeeklyOrMore = resSec >= 604800
|
||
|
||
// Resolve profile bucket
|
||
string profileBucket = "Single"
|
||
if paramMode == "Single"
|
||
profileBucket := "Single"
|
||
else
|
||
if profileOverride == "Minutes"
|
||
profileBucket := "Minutes"
|
||
else if profileOverride == "Hours"
|
||
profileBucket := "Hours"
|
||
else if profileOverride == "Daily"
|
||
profileBucket := "Daily"
|
||
else if profileOverride == "Weekly/Monthly"
|
||
profileBucket := "Weekly/Monthly"
|
||
else
|
||
profileBucket := isMinutes ? "Minutes" : isHours ? "Hours" : isDaily ? "Daily" : "Weekly/Monthly"
|
||
|
||
atrPeriod = profileBucket == "Single" ? atrPeriodSingle : profileBucket == "Minutes" ? atr_m : profileBucket == "Hours" ? atr_h : profileBucket == "Daily" ? atr_d : atr_w
|
||
multiplier = profileBucket == "Single" ? multiplierSingle : profileBucket == "Minutes" ? mult_m : profileBucket == "Hours" ? mult_h : profileBucket == "Daily" ? mult_d : mult_w
|
||
activeProfile := profileBucket
|
||
|
||
// Core Money Line logic (with selectable source)
|
||
// Build selected source OHLC
|
||
// Optimized: Calculate Heikin Ashi directly instead of using request.security()
|
||
haC = srcMode == "Heikin Ashi" ? (open + high + low + close) / 4 : close
|
||
haO = srcMode == "Heikin Ashi" ? (nz(haC[1]) + nz(open[1])) / 2 : open
|
||
haH = srcMode == "Heikin Ashi" ? math.max(high, math.max(haO, haC)) : high
|
||
haL = srcMode == "Heikin Ashi" ? math.min(low, math.min(haO, haC)) : low
|
||
calcH = haH
|
||
calcL = haL
|
||
calcC = haC
|
||
|
||
// ATR on selected source
|
||
tr = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1])))
|
||
atr = ta.rma(tr, atrPeriod)
|
||
src = (calcH + calcL) / 2
|
||
|
||
up = src - (multiplier * atr)
|
||
dn = src + (multiplier * atr)
|
||
|
||
var float up1 = na
|
||
var float dn1 = na
|
||
|
||
up1 := nz(up1[1], up)
|
||
dn1 := nz(dn1[1], dn)
|
||
|
||
up1 := calcC[1] > up1 ? math.max(up, up1) : up
|
||
dn1 := calcC[1] < dn1 ? math.min(dn, dn1) : dn
|
||
|
||
var int trend = 1
|
||
var float tsl = na
|
||
|
||
tsl := nz(tsl[1], up1)
|
||
|
||
if trend == 1
|
||
tsl := math.max(up1, tsl)
|
||
trend := calcC < tsl ? -1 : 1
|
||
else
|
||
tsl := math.min(dn1, tsl)
|
||
trend := calcC > tsl ? 1 : -1
|
||
|
||
supertrend = tsl
|
||
|
||
// Plot the Money Line
|
||
upTrend = trend == 1 ? supertrend : na
|
||
downTrend = trend == -1 ? supertrend : na
|
||
|
||
plot(upTrend, "Up Trend", color=color.new(color.green, 0), style=plot.style_linebr, linewidth=2)
|
||
plot(downTrend, "Down Trend", color=color.new(color.red, 0), style=plot.style_linebr, linewidth=2)
|
||
|
||
// Show active profile on chart as a label (optimized - only on confirmed bar)
|
||
showProfileLabel = input.bool(true, "Show active profile label", group="Profiles")
|
||
var label profLbl = na
|
||
if barstate.islast and barstate.isconfirmed and showProfileLabel
|
||
label.delete(profLbl)
|
||
profLbl := label.new(bar_index, close, text="Profile: " + activeProfile + " | ATR=" + str.tostring(atrPeriod) + " Mult=" + str.tostring(multiplier), yloc=yloc.price, style=label.style_label_upper_left, textcolor=color.white, color=color.new(color.blue, 20))
|
||
|
||
// MACD confirmation logic
|
||
[macdLine, macdSignal, macdHist] = ta.macd(macdSrc, macdFastLen, macdSlowLen, macdSigLen)
|
||
longOk = not useMacd or (macdLine > macdSignal)
|
||
shortOk = not useMacd or (macdLine < macdSignal)
|
||
|
||
// Plot buy/sell signals (gated by optional MACD)
|
||
buyFlip = trend == 1 and trend[1] == -1
|
||
sellFlip = trend == -1 and trend[1] == 1
|
||
|
||
// ADX computation (always calculate for context, but only filter if enabled)
|
||
upMove = calcH - calcH[1]
|
||
downMove = calcL[1] - calcL
|
||
plusDM = (upMove > downMove and upMove > 0) ? upMove : 0.0
|
||
minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0
|
||
trADX = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1])))
|
||
atrADX = ta.rma(trADX, adxLen)
|
||
plusDMSmooth = ta.rma(plusDM, adxLen)
|
||
minusDMSmooth = ta.rma(minusDM, adxLen)
|
||
plusDI = atrADX == 0.0 ? 0.0 : 100.0 * plusDMSmooth / atrADX
|
||
minusDI = atrADX == 0.0 ? 0.0 : 100.0 * minusDMSmooth / atrADX
|
||
dx = (plusDI + minusDI == 0.0) ? 0.0 : 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI)
|
||
adxVal = ta.rma(dx, adxLen)
|
||
adxOk = not useAdx or (adxVal > adxMin)
|
||
|
||
// Entry buffer gates relative to current Money Line
|
||
longBufferOk = not useEntryBuffer or (calcC > supertrend + entryBufferATR * atr)
|
||
shortBufferOk = not useEntryBuffer or (calcC < supertrend - entryBufferATR * atr)
|
||
|
||
// Confirmation bars after flip
|
||
buyReady = ta.barssince(buyFlip) == confirmBars
|
||
sellReady = ta.barssince(sellFlip) == confirmBars
|
||
|
||
// === CONTEXT METRICS FOR SIGNAL QUALITY ===
|
||
// Calculate ATR as percentage of price
|
||
atrPercent = (atr / calcC) * 100
|
||
|
||
// Calculate RSI
|
||
rsi14 = ta.rsi(calcC, 14)
|
||
|
||
// Volume ratio (current volume vs 20-bar MA)
|
||
volMA20 = ta.sma(volume, 20)
|
||
volumeRatio = volume / volMA20
|
||
|
||
// v6 IMPROVEMENT: Price position in 100-bar range (was 20-bar in v5)
|
||
highest100 = ta.highest(calcH, 100) // Changed from 20 to 100
|
||
lowest100 = ta.lowest(calcL, 100) // Changed from 20 to 100
|
||
priceRange = highest100 - lowest100
|
||
pricePosition = priceRange == 0 ? 50.0 : ((calcC - lowest100) / priceRange) * 100
|
||
|
||
// v6 NEW FILTERS
|
||
// Price position filter - prevent chasing extremes
|
||
longPositionOk = not usePricePosition or (pricePosition < longPosMax)
|
||
shortPositionOk = not usePricePosition or (pricePosition > shortPosMin)
|
||
|
||
// Volume filter - avoid dead or overheated moves
|
||
volumeOk = not useVolumeFilter or (volumeRatio >= volMin and volumeRatio <= volMax)
|
||
|
||
// RSI momentum filter
|
||
rsiLongOk = not useRsiFilter or (rsi14 >= rsiLongMin and rsi14 <= rsiLongMax)
|
||
rsiShortOk = not useRsiFilter or (rsi14 >= rsiShortMin and rsi14 <= rsiShortMax)
|
||
|
||
// Final gated signals with v6 filters
|
||
finalLongSignal = buyReady and longOk and adxOk and longBufferOk and longPositionOk and volumeOk and rsiLongOk
|
||
finalShortSignal = sellReady and shortOk and adxOk and shortBufferOk and shortPositionOk and volumeOk and rsiShortOk
|
||
|
||
plotshape(finalLongSignal, title="Buy Signal", location=location.belowbar, color=color.green, style=shape.circle, size=size.small)
|
||
plotshape(finalShortSignal, title="Sell Signal", location=location.abovebar, color=color.red, style=shape.circle, size=size.small)
|
||
|
||
// Extract base currency from ticker (e.g., "ETHUSD" -> "ETH", "SOLUSD" -> "SOL")
|
||
baseCurrency = str.replace(syminfo.ticker, "USD", "")
|
||
baseCurrency := str.replace(baseCurrency, "USDT", "")
|
||
baseCurrency := str.replace(baseCurrency, "PERP", "")
|
||
|
||
// Indicator version for tracking in database
|
||
indicatorVer = "v6"
|
||
|
||
// Build enhanced alert messages with context (timeframe.period is dynamic)
|
||
longAlertMsg = baseCurrency + " buy " + timeframe.period + " | ATR:" + str.tostring(atrPercent, "#.##") + " | ADX:" + str.tostring(adxVal, "#.#") + " | RSI:" + str.tostring(rsi14, "#.#") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePosition, "#.#") + " | IND:" + indicatorVer
|
||
|
||
shortAlertMsg = baseCurrency + " sell " + timeframe.period + " | ATR:" + str.tostring(atrPercent, "#.##") + " | ADX:" + str.tostring(adxVal, "#.#") + " | RSI:" + str.tostring(rsi14, "#.#") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePosition, "#.#") + " | IND:" + indicatorVer
|
||
|
||
// Fire alerts with dynamic messages (use alert() not alertcondition() for dynamic content)
|
||
if finalLongSignal
|
||
alert(longAlertMsg, alert.freq_once_per_bar_close)
|
||
|
||
if finalShortSignal
|
||
alert(shortAlertMsg, alert.freq_once_per_bar_close)
|
||
|
||
// Fill area between price and Money Line
|
||
fill(plot(close, display=display.none), plot(upTrend, display=display.none), color=color.new(color.green, 90))
|
||
fill(plot(close, display=display.none), plot(downTrend, display=display.none), color=color.new(color.red, 90))
|