# SPDX-License-Identifier: MPL-2.0 # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not # distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/ # Origin of the maths: UT Bot idea by Yo_adriiiiaan (from an original idea by HPotter), TradingView "UT Bot Alerts" by # QuantNomad; Supertrend is the textbook ATR-band indicator. """Python statement of the Gold Trend Sentinel rule, on bars you supply (no terminal needed). Signals (on each closed H4 bar, from its own and earlier bars only): UT Bot line (key 1.5, ATR 10, on the close), Supertrend(10, 3) on the bar's (high + low) / 2, and the last completed daily close against its EMA(200) (seeded with the SMA of the first 200 daily closes). buy = close crosses above the UT Bot line AND Supertrend is up AND the daily close is above its EMA sell = the mirror Trade handling (the EA's): market order at the next bar's open (buy at the ask, sell at the bid); stop 3 x ATR(14) from the fill; the stop then trails 3 x ATR(14) (the entry distance) behind the best price since the start of the entry bar, tightened once per bar and never loosened, and if the new stop is already through the open price the trade closes at that open. No target. An opposite signal reverses at the open; a same-side signal is ignored (the trail is still tightened). ATRs are Wilder's (RMA), seeded with the SMA of the first values. Stops are tested on the bar extremes: a buy on the bid low, a sell on the ask high (bid + spread); a gap fills at the open.""" import numpy as np import pandas as pd NAN = np.nan def true_range(h, l, c): tr = h - l tr[1:] = np.maximum(tr[1:], np.maximum(np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1]))) return tr def rma(x, n): out = np.full(len(x), NAN) if len(x) < n: return out out[n - 1] = x[:n].mean() for i in range(n, len(x)): out[i] = (out[i - 1] * (n - 1) + x[i]) / n return out def ut_line(c, atr, key): stop = np.full(len(c), NAN) for i in range(len(c)): if np.isnan(atr[i]): stop[i] = stop[i - 1] if i > 0 else NAN continue prev = stop[i - 1] if i > 0 and not np.isnan(stop[i - 1]) else 0.0 s, s1, nl = c[i], (c[i - 1] if i > 0 else c[i]), key * atr[i] if s > prev and s1 > prev: stop[i] = max(prev, s - nl) elif s < prev and s1 < prev: stop[i] = min(prev, s + nl) else: stop[i] = s - nl if s > prev else s + nl return stop def supertrend_dir(h, l, c, atr, mult): """+1 up, -1 down, 0 before the ATR exists.""" d_out = np.zeros(len(c), int) up = dn = st = 0.0 have = False for i in range(len(c)): if np.isnan(atr[i]): continue src = (h[i] + l[i]) / 2.0 ub, lb = src + mult * atr[i], src - mult * atr[i] if have: lb = lb if (lb > dn or c[i - 1] < dn) else dn ub = ub if (ub < up or c[i - 1] > up) else up if not have: d = 1.0 elif st == up: d = -1.0 if c[i] > ub else 1.0 else: d = 1.0 if c[i] < lb else -1.0 up, dn = ub, lb st = lb if d < 0 else ub have = True d_out[i] = 1 if d < 0 else -1 return d_out def daily_ema_dir(d_close, period=200): """Per daily bar: +1 if its close is above the EMA of the closes up to and including it, -1 below, 0 before it exists.""" out = np.zeros(len(d_close), int) if len(d_close) < period: return out e = d_close[:period].mean() k = 2.0 / (period + 1) out[period - 1] = 1 if d_close[period - 1] > e else (-1 if d_close[period - 1] < e else 0) for i in range(period, len(d_close)): e += k * (d_close[i] - e) out[i] = 1 if d_close[i] > e else (-1 if d_close[i] < e else 0) return out def signals(h4: pd.DataFrame, d1: pd.DataFrame, key=1.5, ut_atr=10, st_atr=10, st_mult=3.0, ema=200): """h4, d1: DataFrames indexed by bar open time (server time) with open, high, low, close. Returns a Series of +1/-1/0 per H4 bar (the signal known when that bar has closed) and the ATR(14) used for the stop.""" o, h, l, c = (h4[k].to_numpy(float) for k in ("open", "high", "low", "close")) tr = true_range(h, l, c) ut = ut_line(c, rma(tr, ut_atr), key) st = supertrend_dir(h, l, c, rma(tr, st_atr), st_mult) atr14 = rma(tr, 14) ddir = daily_ema_dir(d1.close.to_numpy(float), ema) d_end = d1.index + pd.Timedelta(days=1) # a daily bar is complete at the next day's open h4_end = h4.index + (h4.index[1] - h4.index[0]) pos = np.searchsorted(d_end.values, h4_end.values, side="right") - 1 # last daily bar completed by the H4 close sig = np.zeros(len(c), int) for j in range(1, len(c)): if np.isnan(ut[j]) or np.isnan(ut[j - 1]) or np.isnan(atr14[j]) or pos[j] < 0: continue dd = ddir[pos[j]] if c[j] > ut[j] and c[j - 1] <= ut[j - 1] and dd > 0 and st[j] > 0: sig[j] = 1 elif c[j] < ut[j] and c[j - 1] >= ut[j - 1] and dd < 0 and st[j] < 0: sig[j] = -1 return pd.Series(sig, index=h4.index), pd.Series(atr14, index=h4.index) def run(h4: pd.DataFrame, d1: pd.DataFrame, spread: pd.Series, stop_mult=3.0, **kw) -> pd.DataFrame: """Trades (gross of swap). h4 holds bid open/high/low/close; `spread` is per H4 bar in price units (ask = bid + spread).""" sig, atr = signals(h4, d1, **kw) sig = sig.to_numpy() bo, bh, bl = (h4[k].to_numpy(float) for k in ("open", "high", "low")) sp = spread.to_numpy(float) ao, ah, al = bo + sp, bh + sp, bl + sp n, trades = len(h4), [] pos, fill, sl, dist, k_in = 0, 0.0, 0.0, 0.0, 0 def close(k, px, why): nonlocal pos pnl = pos * (px - fill) trades.append(dict(entry_time=h4.index[k_in], exit_time=h4.index[k], dir=pos, entry=fill, exit=px, pnl=pnl, r=pnl / dist, dist=dist, why=why)) pos = 0 def trail(k): nonlocal sl if pos == 0 or k_in >= k: return best = max(fill, bh[k_in:k].max()) if pos > 0 else min(fill, al[k_in:k].min()) ns = best - dist if pos > 0 else best + dist if (pos > 0 and ns <= sl) or (pos < 0 and ns >= sl): return if (pos > 0 and ns >= bo[k]) or (pos < 0 and ns <= ao[k]): close(k, bo[k] if pos > 0 else ao[k], "trail through the price") return sl = ns for k in range(1, n): # the stop is tested inside the previous bar (k-1) with the stop in force during it if pos != 0 and k - 1 >= k_in: j = k - 1 if pos > 0 and bl[j] <= sl: close(j, min(sl, bo[j]), "stop") elif pos < 0 and ah[j] >= sl: close(j, max(sl, ao[j]), "stop") s = sig[k - 1] # the bar that just closed decides; the order goes out at bar k's open if s != 0: if pos == s: trail(k) else: if pos == -s: close(k, bo[k] if pos > 0 else ao[k], "reversed") if pos == 0 and not np.isnan(atr.iloc[k - 1]): fill = ao[k] if s > 0 else bo[k] dist = stop_mult * atr.iloc[k - 1] sl = fill - dist if s > 0 else fill + dist pos, k_in = s, k else: trail(k) return pd.DataFrame(trades)