ATLAS_Probabilistic_Reach_Map.pine
Copy
//@version=5
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © AssetWhisper — https://assetwhisper.com/indicators/
// ============================================================================
// ATLAS — Adaptive Touch-probability Landscape & Self-calibration
//
// Cambia la pregunta. Los indicadores clásicos responden "¿entro?". ATLAS responde
// "¿a dónde puede llegar el precio, cuándo, con qué probabilidad, cuánto cuesta
// llegar y me puedo fiar de ese cálculo hoy?". Cuatro piezas que no existen
// juntas en TradingView:
//
// 1) MAPA DE PROBABILIDAD DE TOQUE. Para una rejilla de niveles (±k pasos) y
// horizontes (1x, 2x, 4x, 8x, 16x barras) calcula la probabilidad de que el
// precio TOQUE cada nivel dentro de cada horizonte con la fórmula cerrada del
// primer paso de un browniano con drift (distribución inversa gaussiana):
// P(τ_a ≤ T) = Φ((−a + μT)/(σ√T)) + e^{2μa/σ²} · Φ((−a − μT)/(σ√T))
// y lo dibuja como un mapa de calor en el espacio futuro precio-tiempo, a la
// derecha de la última vela. Es la "superficie de probabilidad de toque" de
// las opciones, pero sobre el gráfico y sin necesitar datos de opciones.
//
// 2) RÉGIMEN POR HMM. Un modelo oculto de Markov gaussiano de 2 estados (calma /
// estrés) con filtro hacia delante y parámetros que se aprenden en línea
// (EM estocástico). El mapa usa la mezcla de ambos estados ponderada por la
// probabilidad filtrada: en estrés el mapa se ensancha y se inclina solo.
//
// 3) COSTES DESDE LAS VELAS. Spread efectivo con el estimador de Corwin-Schultz
// (solo máximos y mínimos) e impacto de mercado con la iliquidez de Amihud
// (|retorno| / volumen monetario). Con tu tamaño de orden estima cuánto
// moverías el precio y cuánto cuesta el viaje de ida y vuelta; los objetivos
// que no superan 3× ese coste se marcan como "zona de ruido".
//
// 4) AUTO-CALIBRACIÓN. Cada barra registra su propia predicción (P de tocar ±1 ATR
// en H barras), la resuelve H barras después y calcula el Brier score y el
// Brier Skill Score frente a la base climatológica. Si el modelo no tiene
// habilidad en este activo/timeframe lo dice, y (opcional) corrige el mapa con
// el factor empírico frecuencia observada / probabilidad predicha.
//
// Salidas: mapa de calor, mejor objetivo neto de costes arriba y abajo, ratio de
// ventaja direccional, P(estrés), tabla, alertas y series exportables.
// ============================================================================
indicator("ATLAS — Probabilistic Reach Map", shorttitle="ATLAS", overlay=true, max_boxes_count=200, max_labels_count=100)
// === INPUTS ===
grpG = "Mapa"
levelsK = input.int(4, "Levels per side", minval=2, maxval=6, group=grpG)
stepMode = input.string("ATR", "Level step unit", options=["ATR", "Percent"], group=grpG)
stepSize = input.float(1.0, "Level step (ATR mult or %)", minval=0.1, maxval=10, step=0.1, group=grpG)
baseH = input.int(5, "Base horizon (bars); columns = 1x 2x 4x 8x 16x", minval=2, maxval=100, group=grpG)
colWidth = input.int(6, "Column width (bars)", minval=2, maxval=20, group=grpG)
showText = input.bool(true, "Show % in cells", group=grpG)
grpR = "Régimen (HMM 2 estados)"
pStayCalm = input.float(0.97, "P(stay calm)", minval=0.5, maxval=0.999, step=0.005, group=grpR)
pStayStress = input.float(0.92, "P(stay stress)", minval=0.5, maxval=0.999, step=0.005, group=grpR)
eta = input.float(0.02, "Online learning rate", minval=0.001, maxval=0.2, step=0.001, group=grpR)
grpC = "Costes y liquidez"
spreadLen = input.int(20, "Spread estimator window (Corwin-Schultz)", minval=5, maxval=200, group=grpC)
illiqLen = input.int(60, "Impact estimator window (Amihud)", minval=10, maxval=500, group=grpC)
orderNotional = input.float(10000, "Your order notional (quote currency)", minval=0, group=grpC)
grpS = "Auto-calibración"
calibH = input.int(20, "Calibration horizon (bars)", minval=3, maxval=100, group=grpS)
calibN = input.int(150, "Calibration sample (predictions)", minval=30, maxval=400, group=grpS)
applyCal = input.bool(true, "Apply empirical calibration to the map", group=grpS)
grpV = "Visual"
showTable = input.bool(true, "Show table", group=grpV)
showStress = input.bool(true, "Stress background", group=grpV)
// === BASE ===
lr = math.log(close / close[1])
atr = ta.atr(14)
sd50 = ta.stdev(lr, 50)
clampP(x) => math.max(0.0, math.min(1.0, x))
// ============================================================================
// 1 · RÉGIMEN: HMM gaussiano de 2 estados, filtro forward + EM estocástico
// ============================================================================
var bool hmmInit = false
var float mu0 = 0.0
var float mu1 = 0.0
var float v0 = 1e-6
var float v1 = 4e-6
var float pCalm = 0.8
if not hmmInit and not na(sd50) and sd50 > 0
mu0 := sd50 * 0.1
mu1 := -sd50 * 0.2
v0 := math.pow(sd50 * 0.7, 2)
v1 := math.pow(sd50 * 1.6, 2)
hmmInit := true
gaussPdf(x, m, v) =>
v > 0 ? math.exp(-(x - m) * (x - m) / (2 * v)) / math.sqrt(2 * math.pi * v) : 0.0
if hmmInit and not na(lr)
prior0 = pCalm * pStayCalm + (1 - pCalm) * (1 - pStayStress)
prior1 = 1 - prior0
f0 = gaussPdf(lr, mu0, v0)
f1 = gaussPdf(lr, mu1, v1)
denom = prior0 * f0 + prior1 * f1
post0 = denom > 0 ? prior0 * f0 / denom : prior0
pCalm := math.max(0.001, math.min(0.999, post0))
post1 = 1 - pCalm
// actualización recursiva de parámetros ponderada por la probabilidad a posteriori
mu0 := mu0 + eta * pCalm * (lr - mu0)
v0 := v0 + eta * pCalm * ((lr - mu0) * (lr - mu0) - v0)
mu1 := mu1 + eta * post1 * (lr - mu1)
v1 := v1 + eta * post1 * ((lr - mu1) * (lr - mu1) - v1)
// el estado 1 es siempre el más volátil (evita intercambio de etiquetas)
v0 := math.max(v0, 1e-10)
v1 := math.max(v1, v0 * 1.2)
pStress = 1 - pCalm
sig0 = math.sqrt(v0)
sig1 = math.sqrt(v1)
muMix = pCalm * mu0 + pStress * mu1
varMix = pCalm * (v0 + mu0 * mu0) + pStress * (v1 + mu1 * mu1) - muMix * muMix
sigMix = math.sqrt(math.max(varMix, 1e-12))
expDurStress = 1 / (1 - pStayStress)
expDurCalm = 1 / (1 - pStayCalm)
inStress = pStress > 0.5
// ============================================================================
// 2 · COSTES: spread efectivo (Corwin-Schultz) e impacto (Amihud)
// ============================================================================
hl = math.log(high / low)
beta = hl * hl + hl[1] * hl[1]
hh2 = math.max(high, high[1])
ll2 = math.min(low, low[1])
gamma = math.pow(math.log(hh2 / ll2), 2)
k2 = 3 - 2 * math.sqrt(2)
alphaCS = math.max(0.0, (math.sqrt(2 * beta) - math.sqrt(beta)) / k2 - math.sqrt(gamma / k2))
spreadCS = 2 * (math.exp(alphaCS) - 1) / (1 + math.exp(alphaCS))
spread = ta.sma(spreadCS, spreadLen) // fracción del precio
spreadPct = spread * 100
hasVolume = not na(volume) and volume > 0
dollarVol = volume * close
illiqRaw = hasVolume ? math.abs(lr) / dollarVol : 0.0
illiq = ta.sma(illiqRaw, illiqLen) // |r| por unidad monetaria negociada
impactPct = hasVolume ? illiq * orderNotional * 100 : na // impacto estimado de tu orden (%)
volToMoveATR = hasVolume and illiq > 0 ? (atr / close) / illiq : na
roundTrip = spreadPct + 2 * nz(impactPct, 0.0) // coste ida y vuelta (%)
// ============================================================================
// 3 · PROBABILIDAD DE TOQUE (primer paso de un browniano con drift)
// ============================================================================
normCdf(x) =>
t = 1 / (1 + 0.2316419 * math.abs(x))
d = 0.3989422804 * math.exp(-x * x / 2)
p = 1 - d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))))
x >= 0 ? p : 1 - p
// aLog > 0: distancia (log) a la barrera; drift y vol por barra; T en barras
touchProb(aLog, mu, sig, T) =>
if aLog <= 0
1.0
else if sig <= 0 or T <= 0
mu * T >= aLog ? 1.0 : 0.0
else
sT = sig * math.sqrt(T)
ex = math.exp(math.max(-50.0, math.min(50.0, 2 * mu * aLog / (sig * sig))))
clampP(normCdf((-aLog + mu * T) / sT) + ex * normCdf((-aLog - mu * T) / sT))
// mezcla de los dos estados del HMM (asume persistencia del régimen en el horizonte)
touchMix(aLog, upward, T) =>
m0 = upward ? mu0 : -mu0
m1 = upward ? mu1 : -mu1
pCalm * touchProb(aLog, m0, sig0, T) + pStress * touchProb(aLog, m1, sig1, T)
// ============================================================================
// 4 · AUTO-CALIBRACIÓN: Brier score y Brier Skill Score sobre predicciones propias
// ============================================================================
var array<float> cpUp = array.new<float>()
var array<float> cpDn = array.new<float>()
var array<int> cStart = array.new<int>()
var array<float> cLvlUp = array.new<float>()
var array<float> cLvlDn = array.new<float>()
var array<float> cMaxH = array.new<float>()
var array<float> cMinL = array.new<float>()
var array<float> rP = array.new<float>() // predicción
var array<float> rO = array.new<float>() // resultado (0/1)
nPend = array.size(cStart)
if nPend > 0
for i = nPend - 1 to 0
array.set(cMaxH, i, math.max(array.get(cMaxH, i), high))
array.set(cMinL, i, math.min(array.get(cMinL, i), low))
if bar_index - array.get(cStart, i) >= calibH
oUp = array.get(cMaxH, i) >= array.get(cLvlUp, i) ? 1.0 : 0.0
oDn = array.get(cMinL, i) <= array.get(cLvlDn, i) ? 1.0 : 0.0
array.push(rP, array.get(cpUp, i))
array.push(rO, oUp)
array.push(rP, array.get(cpDn, i))
array.push(rO, oDn)
while array.size(rP) > calibN
array.shift(rP)
array.shift(rO)
array.remove(cpUp, i)
array.remove(cpDn, i)
array.remove(cStart, i)
array.remove(cLvlUp, i)
array.remove(cLvlDn, i)
array.remove(cMaxH, i)
array.remove(cMinL, i)
// nueva predicción: tocar ±1 ATR dentro de calibH barras
if hmmInit and atr > 0
lvlUpC = close + atr
lvlDnC = close - atr
aUpC = math.log(lvlUpC / close)
pUpC = touchMix(aUpC, true, calibH)
pDnC = lvlDnC > 0 ? touchMix(math.log(close / lvlDnC), false, calibH) : 0.5
array.push(cpUp, pUpC)
array.push(cpDn, pDnC)
array.push(cStart, bar_index)
array.push(cLvlUp, lvlUpC)
array.push(cLvlDn, lvlDnC)
array.push(cMaxH, close)
array.push(cMinL, close)
nB = array.size(rP)
bsSum = 0.0
oSum = 0.0
pSum = 0.0
if nB > 0
for i = 0 to nB - 1
pp = array.get(rP, i)
oo = array.get(rO, i)
bsSum += (pp - oo) * (pp - oo)
oSum += oo
pSum += pp
brier = nB > 0 ? bsSum / nB : na
oBar = nB > 0 ? oSum / nB : na
pBar = nB > 0 ? pSum / nB : na
bsRef = nB > 0 ? oBar * (1 - oBar) : na // Brier de la base climatológica
bss = not na(bsRef) and bsRef > 0 ? 1 - brier / bsRef : na
calFactor = not na(pBar) and pBar > 0 ? math.max(0.5, math.min(1.5, oBar / pBar)) : 1.0
calApplied = applyCal and nB >= 30 ? calFactor : 1.0
skillLabel = na(bss) ? "n/a" : bss > 0.1 ? "SKILLED" : bss > 0 ? "MARGINAL" : "NO SKILL"
// ============================================================================
// VENTAJA DIRECCIONAL (columna central del mapa, primer nivel)
// ============================================================================
stepAbs = stepMode == "ATR" ? stepSize * atr : close * stepSize / 100
midT = baseH * 4
pUpMid = stepAbs > 0 ? clampP(touchMix(math.log((close + stepAbs) / close), true, midT) * calApplied) : na
pDnMid = stepAbs > 0 and close - stepAbs > 0 ? clampP(touchMix(math.log(close / (close - stepAbs)), false, midT) * calApplied) : na
edgeRatio = not na(pUpMid) and not na(pDnMid) and pDnMid > 0 ? pUpMid / pDnMid : na
noiseStep = stepAbs / close * 100 < 3 * roundTrip
// ============================================================================
// MAPA DE CALOR EN EL ESPACIO FUTURO PRECIO-TIEMPO
// ============================================================================
nCols = 5
var array<box> cells = array.new<box>()
var array<label> colLbls = array.new<label>()
string bestUpTxt = "-"
string bestDnTxt = "-"
if barstate.islast and hmmInit and stepAbs > 0
if array.size(cells) == 0
for i = 0 to 2 * levelsK * nCols - 1
array.push(cells, box.new(bar_index, close, bar_index, close, border_color=color.new(color.gray, 75), bgcolor=color.new(color.gray, 90), text_size=size.tiny, text_color=color.white))
for j = 0 to nCols - 1
array.push(colLbls, label.new(bar_index, close, "", style=label.style_label_down, color=color.new(color.black, 60), textcolor=color.white, size=size.tiny))
bestUp = -1e9
bestDn = -1e9
for j = 0 to nCols - 1
Tj = baseH * math.round(math.pow(2, j))
x1 = bar_index + 1 + j * colWidth
x2 = x1 + colWidth - 1
lb = array.get(colLbls, j)
label.set_xy(lb, x1 + math.floor(colWidth / 2), close + levelsK * stepAbs)
label.set_text(lb, str.tostring(Tj) + "b")
for k = 1 to levelsK
distPct = k * stepAbs / close * 100
isNoise = distPct < 3 * roundTrip
// --- arriba ---
lvlU = close + k * stepAbs
pU = clampP(touchMix(math.log(lvlU / close), true, Tj) * calApplied)
bxU = array.get(cells, j * 2 * levelsK + (k - 1))
box.set_lefttop(bxU, x1, lvlU)
box.set_rightbottom(bxU, x2, lvlU - stepAbs)
box.set_bgcolor(bxU, color.from_gradient(pU, 0, 1, color.new(color.green, 93), color.new(color.green, 30)))
box.set_border_color(bxU, isNoise ? color.new(color.orange, 40) : color.new(color.gray, 75))
box.set_text(bxU, showText ? str.tostring(pU * 100, "#") + "%" : "")
netU = distPct - roundTrip
if netU > 0 and pU * netU > bestUp
bestUp := pU * netU
bestUpTxt := "+" + str.tostring(k) + " step (" + str.tostring(lvlU, format.mintick) + ") in " + str.tostring(Tj) + "b · P " + str.tostring(pU * 100, "#") + "% · net " + str.tostring(netU, "#.##") + "%"
// --- abajo ---
lvlD = close - k * stepAbs
pD = lvlD > 0 ? clampP(touchMix(math.log(close / lvlD), false, Tj) * calApplied) : 1.0
bxD = array.get(cells, j * 2 * levelsK + levelsK + (k - 1))
box.set_lefttop(bxD, x1, lvlD + stepAbs)
box.set_rightbottom(bxD, x2, lvlD)
box.set_bgcolor(bxD, color.from_gradient(pD, 0, 1, color.new(color.red, 93), color.new(color.red, 30)))
box.set_border_color(bxD, isNoise ? color.new(color.orange, 40) : color.new(color.gray, 75))
box.set_text(bxD, showText ? str.tostring(pD * 100, "#") + "%" : "")
netD = distPct - roundTrip
if netD > 0 and pD * netD > bestDn
bestDn := pD * netD
bestDnTxt := "-" + str.tostring(k) + " step (" + str.tostring(lvlD, format.mintick) + ") in " + str.tostring(Tj) + "b · P " + str.tostring(pD * 100, "#") + "% · net " + str.tostring(netD, "#.##") + "%"
// ============================================================================
// VISUAL, TABLA, EXPORTS, ALERTAS
// ============================================================================
bgcolor(showStress ? color.new(color.red, math.round(100 - 12 * pStress)) : na, title="Stress probability")
plot(pStress, "ATLAS P(stress) (export)", display=display.none)
plot(edgeRatio, "ATLAS edge ratio up/down (export)", display=display.none)
plot(bss, "ATLAS Brier skill (export)", display=display.none)
plot(roundTrip, "ATLAS round-trip cost % (export)", display=display.none)
plot(pUpMid, "ATLAS P(up 1 step, mid horizon) (export)", display=display.none)
plot(pDnMid, "ATLAS P(down 1 step, mid horizon) (export)", display=display.none)
if showTable
var table t = table.new(position.top_right, 2, 15, bgcolor=color.new(color.black, 55))
table.cell(t, 0, 0, "REGIME", text_color=color.white, text_size=size.small)
table.cell(t, 1, 0, (inStress ? "STRESS " : "CALM ") + str.tostring(pStress * 100, "#") + "% stress", bgcolor=color.new(inStress ? color.red : color.green, 40), text_color=color.white, text_size=size.small)
table.cell(t, 0, 1, "Calm μ / σ per bar", text_color=color.white, text_size=size.small)
table.cell(t, 1, 1, str.tostring(mu0 * 100, "#.###") + "% / " + str.tostring(sig0 * 100, "#.##") + "%", text_color=color.gray, text_size=size.small)
table.cell(t, 0, 2, "Stress μ / σ per bar", text_color=color.white, text_size=size.small)
table.cell(t, 1, 2, str.tostring(mu1 * 100, "#.###") + "% / " + str.tostring(sig1 * 100, "#.##") + "%", text_color=color.gray, text_size=size.small)
table.cell(t, 0, 3, "Expected regime length", text_color=color.white, text_size=size.small)
table.cell(t, 1, 3, "calm ~" + str.tostring(expDurCalm, "#") + "b · stress ~" + str.tostring(expDurStress, "#") + "b", text_color=color.gray, text_size=size.small)
table.cell(t, 0, 4, "Effective spread (CS)", text_color=color.white, text_size=size.small)
table.cell(t, 1, 4, str.tostring(spreadPct, "#.###") + "%", text_color=color.orange, text_size=size.small)
table.cell(t, 0, 5, "Impact of your order", text_color=color.white, text_size=size.small)
table.cell(t, 1, 5, na(impactPct) ? "n/a (no volume)" : str.tostring(impactPct, "#.###") + "%", text_color=color.orange, text_size=size.small)
table.cell(t, 0, 6, "Round-trip cost", text_color=color.white, text_size=size.small)
table.cell(t, 1, 6, str.tostring(roundTrip, "#.###") + "%" + (noiseStep ? " ⚠ step < 3× cost" : ""), text_color=noiseStep ? color.red : color.orange, text_size=size.small)
table.cell(t, 0, 7, "Notional to move 1 ATR", text_color=color.white, text_size=size.small)
table.cell(t, 1, 7, na(volToMoveATR) ? "n/a" : str.tostring(volToMoveATR, "#"), text_color=color.gray, text_size=size.small)
table.cell(t, 0, 8, "Brier / skill (n=" + str.tostring(nB) + ")", text_color=color.white, text_size=size.small)
table.cell(t, 1, 8, (na(brier) ? "-" : str.tostring(brier, "#.###")) + " / " + (na(bss) ? "-" : str.tostring(bss, "#.##")) + " " + skillLabel, text_color=na(bss) ? color.gray : bss > 0.1 ? color.lime : bss > 0 ? color.orange : color.red, text_size=size.small)
table.cell(t, 0, 9, "Calibration (obs/pred)", text_color=color.white, text_size=size.small)
table.cell(t, 1, 9, str.tostring(calFactor, "#.##") + (applyCal and nB >= 30 ? " applied" : " not applied"), text_color=color.gray, text_size=size.small)
table.cell(t, 0, 10, "P(+1 step) / P(-1 step) @" + str.tostring(midT) + "b", text_color=color.white, text_size=size.small)
table.cell(t, 1, 10, (na(pUpMid) ? "-" : str.tostring(pUpMid * 100, "#") + "%") + " / " + (na(pDnMid) ? "-" : str.tostring(pDnMid * 100, "#") + "%"), text_color=color.white, text_size=size.small)
table.cell(t, 0, 11, "Edge ratio up/down", text_color=color.white, text_size=size.small)
table.cell(t, 1, 11, na(edgeRatio) ? "-" : str.tostring(edgeRatio, "#.##"), text_color=na(edgeRatio) ? color.gray : edgeRatio > 1.25 ? color.lime : edgeRatio < 0.8 ? color.red : color.white, text_size=size.small)
table.cell(t, 0, 12, "Best net target ↑", text_color=color.white, text_size=size.small)
table.cell(t, 1, 12, bestUpTxt, text_color=color.lime, text_size=size.small)
table.cell(t, 0, 13, "Best net target ↓", text_color=color.white, text_size=size.small)
table.cell(t, 1, 13, bestDnTxt, text_color=color.red, text_size=size.small)
table.cell(t, 0, 14, "Map", text_color=color.white, text_size=size.small)
table.cell(t, 1, 14, str.tostring(levelsK) + "×" + str.tostring(stepSize, "#.##") + (stepMode == "ATR" ? " ATR" : "%") + " · " + str.tostring(baseH) + "b base · orange border = noise zone", text_color=color.gray, text_size=size.small)
// === ALERTAS ===
alertcondition(inStress and not inStress[1], "ATLAS Stress regime", "ATLAS: HMM switched to STRESS state")
alertcondition(not inStress and inStress[1], "ATLAS Calm regime", "ATLAS: HMM switched back to CALM state")
alertcondition(not na(edgeRatio) and edgeRatio > 1.5 and edgeRatio[1] <= 1.5 and not na(bss) and bss > 0, "ATLAS Upside edge", "ATLAS: upside touch probability > 1.5x downside with positive model skill")
alertcondition(not na(edgeRatio) and edgeRatio < 0.67 and edgeRatio[1] >= 0.67 and not na(bss) and bss > 0, "ATLAS Downside edge", "ATLAS: downside touch probability > 1.5x upside with positive model skill")
alertcondition(not na(bss) and bss < 0 and bss[1] >= 0, "ATLAS Model lost skill", "ATLAS: Brier skill turned negative - do not trust the map")
Show all 352 lines