# 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 follows the widely published TradingView ATR-band script; Range Filter is an own implementation of the # published range-filter method. """Python statement of the three signal engines of Signal Trader (UT Bot, SuperTrend, Range Filter) and of its trade rule, written from the indicators' published semantics (not translated from the MQL code). No terminal needed. sig = signals("st", dict(atr=10, mult=2.0), o, h, l, c) # +1 buy / -1 sell / 0 per closed bar trades = run(bars, sig, stop_atr=3.0) # bars: DataFrame open, high, low, close, spread Engines (each reports a signal for a CLOSED bar only; the last array bar is treated as still forming): ut UT Bot Alerts: ATR trailing stop on the close (key value x Wilder ATR), buy/sell on the cross. st SuperTrend: hl2 +/- multiplier x Wilder ATR bands; a signal when the trend flips. rf Range Filter: the filter moves only when price travels more than the smoothed range from it; a signal is the first buy setup after a sell setup (and vice versa). Trade rule (the EA's): a signal on closed bar i is acted on at the open of bar i+1; an opposite position is closed at that open and reversed; a same-side signal is ignored; otherwise a market order. The stop is stop_atr x ATR(14, Wilder) of bar i, no target. A buy fills at the ask (open + spread), a sell at the bid (open); a buy stop is hit when the bid low touches it, a sell stop when the ask (high + spread) does; a gap fills at the stop level (the tester's behaviour).""" import numpy as np import pandas as pd NAN = np.nan def above(a, b): """Strict a > b ignoring floating-point noise (a band can tie a close exactly in real arithmetic); same as MQL5.""" return a - b > 1e-10 * abs(b) def below(a, b): return b - a > 1e-10 * abs(b) 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 sma(x, n): """Plain SMA. Each window is summed left to right (like Pine's ta.sma and the MQL5 loop); a running cumsum difference would carry ~1e-13 noise, and SuperTrend compares close against bands that can tie exactly.""" out = np.full(len(x), NAN) for i in range(n - 1, len(x)): s = 0.0 for v in x[i - n + 1:i + 1]: s += v out[i] = s / n return out def rma(x, n): """RMA seeded with the SMA of x[:n]; x may carry leading NaN (then seeded at the first full window).""" out = np.full(len(x), NAN) first = int(np.argmax(~np.isnan(x))) if (~np.isnan(x)).any() else len(x) s = first + n - 1 if s >= len(x): return out out[s] = x[first:first + n].mean() for i in range(s + 1, len(x)): out[i] = (out[i - 1] * (n - 1) + x[i]) / n return out def ema(x, n): out = np.full(len(x), NAN) if len(x) < n: return out out[n - 1] = x[:n].mean() k = 2.0 / (n + 1) for i in range(n, len(x)): out[i] = out[i - 1] + k * (x[i] - out[i - 1]) return out def supertrend(o, h, l, c, period=10, mult=3.0, rma_atr=True, use_ema=False, ema_period=200): """Returns (line, buy, sell) arrays; buy/sell hold the arrow price (low/high) or NaN. The last bar is treated as still forming: no signal on it.""" n = len(c) src = (h + l) / 2.0 tr = true_range(h, l, c) atr = rma(tr, period) if rma_atr else sma(tr, period) e = ema(c, ema_period) up, dn, trend = np.full(n, NAN), np.full(n, NAN), np.full(n, NAN) line, buy, sell = np.full(n, NAN), np.full(n, NAN), np.full(n, NAN) for i in range(n): if np.isnan(atr[i]): continue u, d = src[i] - mult * atr[i], src[i] + mult * atr[i] u1 = up[i - 1] if i > 0 and not np.isnan(up[i - 1]) else u # nz(up[1], up) d1 = dn[i - 1] if i > 0 and not np.isnan(dn[i - 1]) else d if i > 0 and above(c[i - 1], u1): u = max(u, u1) if i > 0 and below(c[i - 1], d1): d = min(d, d1) t_prev = trend[i - 1] if i > 0 and not np.isnan(trend[i - 1]) else 1.0 t = t_prev if t == -1 and above(c[i], d1): t = 1.0 elif t == 1 and below(c[i], u1): t = -1.0 up[i], dn[i], trend[i] = u, d, t line[i] = u if t == 1 else d if i == 0 or i == n - 1 or np.isnan(trend[i - 1]): continue b, s = t == 1 and trend[i - 1] == -1, t == -1 and trend[i - 1] == 1 if use_ema: b = b and not np.isnan(e[i]) and c[i] > e[i] s = s and not np.isnan(e[i]) and c[i] < e[i] if b: buy[i] = l[i] if s: sell[i] = h[i] return line, buy, sell def heikin_close(o, h, l, c): return (o + h + l + c) / 4.0 def utbot(o, h, l, c, key=1.0, atr_period=10, heikin=False, use_ema=False, ema_period=200): """Returns (trail, buy, sell) arrays; buy/sell hold the arrow price (low/high) or NaN. The last bar is treated as still forming: no signal on it.""" n = len(c) src = heikin_close(o, h, l, c) if heikin else c.copy() atr = rma(true_range(h, l, c), atr_period) e = ema(c, ema_period) trail = np.full(n, NAN) buy, sell = np.full(n, NAN), np.full(n, NAN) for i in range(n): if np.isnan(atr[i]): continue prev = trail[i - 1] if i > 0 and not np.isnan(trail[i - 1]) else 0.0 s, s1, nl = src[i], (src[i - 1] if i > 0 else src[i]), key * atr[i] if s > prev and s1 > prev: trail[i] = max(prev, s - nl) elif s < prev and s1 < prev: trail[i] = min(prev, s + nl) else: trail[i] = s - nl if s > prev else s + nl if i == 0 or i == n - 1 or np.isnan(trail[i - 1]): continue up = s > trail[i] and s1 <= trail[i - 1] dn = s < trail[i] and s1 >= trail[i - 1] if use_ema: up = up and not np.isnan(e[i]) and c[i] > e[i] dn = dn and not np.isnan(e[i]) and c[i] < e[i] if up: buy[i] = l[i] if dn: sell[i] = h[i] return trail, buy, sell def range_filter_run(src, period=100, mult=3.0): rows = [] prev = 0.0 avrng = smrng = rng = filt = prev_filt = None up = dn = cond = 0 a1 = 2.0 / (period + 1) wper = 2 * period - 1 a2 = 2.0 / (wper + 1) for k, x in enumerate(src): ev = 0 if k >= 1: d = abs(x - prev) avrng = d if avrng is None else avrng + a1 * (d - avrng) smrng = avrng if smrng is None else smrng + a2 * (avrng - smrng) rng = smrng * mult prev_filt = filt if filt is None: filt = x else: f, r = filt, rng if x > f: filt = f if x - r < f else x - r else: filt = f if x + r > f else x + r if prev_filt is not None: if filt > prev_filt: up, dn = up + 1, 0 elif filt < prev_filt: dn, up = dn + 1, 0 moved = x != prev buy = x > filt and moved and up > 0 sell = x < filt and moved and dn > 0 p = cond if buy: cond = 1 elif sell: cond = -1 if buy and p == -1: ev = 1 elif sell and p == 1: ev = -1 prev = x rows.append(dict(filt=filt, range=rng, up=up, dn=dn, cond=cond, ev=ev, side=1 if up > 0 else (-1 if dn > 0 else 0))) return rows def atr14(h, l, c, n=14): """Wilder ATR (RMA seeded with the SMA of the first n true ranges): the unit of the stop distance.""" return rma(true_range(h, l, c), n) def _flags(buy, sell): return np.where(~np.isnan(buy), 1, 0) + np.where(~np.isnan(sell), -1, 0) def signals(source, p, o, h, l, c): o, h, l, c = (np.ascontiguousarray(x, dtype=float) for x in (o, h, l, c)) if source == "ut": _, b, s = utbot(o, h, l, c, key=p["key"], atr_period=p["atr"], heikin=False, use_ema=bool(p.get("ema", 0))) return _flags(b, s) if source == "st": _, b, s = supertrend(o, h, l, c, period=p["atr"], mult=p["mult"], use_ema=bool(p.get("ema", 0))) return _flags(b, s) if source == "rf": out = np.array([r["ev"] for r in range_filter_run(list(c), p["period"], p["mult"])], dtype=int) out[-1] = 0 # the last array bar is still forming return out raise ValueError(source) def run(bars, sig, stop_atr=3.0): """Trades on bars (DataFrame with open, high, low, close and spread in price units), gross of swap. Columns: side, entry_bar, exit_bar (bar open times), entry, exit, dist, r, why.""" o, h, l, c = (bars[k].to_numpy(float) for k in ("open", "high", "low", "close")) sp = bars["spread"].to_numpy(float) idx, n = bars.index, len(c) atr = atr14(h, l, c) rows, cur = [], None def locate(k_in, side, sl): for k in range(k_in, n): if side > 0 and l[k] <= sl: return k, sl, "stop" if side < 0 and h[k] + sp[k] >= sl: return k, sl, "stop" return None def close(k, px, why): nonlocal cur pnl = (px - cur["entry"]) * cur["side"] rows.append(dict(side=cur["side"], entry_bar=idx[cur["k_in"]], exit_bar=idx[k], entry=cur["entry"], exit=px, dist=cur["dist"], r=pnl / cur["dist"], why=why)) cur = None for i in np.flatnonzero(sig != 0): k = i + 1 if k >= n or np.isnan(atr[i]): continue s = int(sig[i]) if cur is not None and cur["exit"] is not None and cur["exit"][0] < k: close(*cur["exit"]) if cur is not None: if cur["side"] == s: continue close(k, o[k] if cur["side"] > 0 else o[k] + sp[k], "reverse") dist = stop_atr * atr[i] entry = o[k] + sp[k] if s > 0 else o[k] sl = entry - dist if s > 0 else entry + dist cur = dict(side=s, k_in=k, entry=entry, dist=dist, exit=locate(k, s, sl)) if cur is not None and cur["exit"] is not None: close(*cur["exit"]) return pd.DataFrame(rows)