smc-zones/tests/reference.py
194 lines
9.2 KiB
Python
# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 (see LICENSE)
"""Independent Python reference for SMC Zones, bar by bar. Swings, sweeps, BOS/CHoCH, order block and FVG
written as a closed-bar state machine.
Everything at bar i is decided from bars <= i only; the last bar of the array is the forming bar and is never used.
A zone's creation bar and mitigation bar, once set, never change.
Rules (dir +1 = bullish, -1 = bearish):
ATR Wilder (RMA) of the true range, seeded with the SMA of the first `atr` values.
swing bar p is a swing high (low) if each of the `swing` bars to its left has a strictly lower high (higher low)
and each of the `swing` bars to its right a high not above (low not below). Known at bar p + swing.
BOS/CHoCH a close beyond the newest unbroken swing high (low). CHoCH when the previous break went the other way,
otherwise BOS. The first break ever is a BOS.
order block the zone left by a break: org = lowest low (highest high) bar from the broken swing to the break bar.
If org is a candle against the move (bearish candle for a bullish break), the OB candle is the last of the
run of against-candles starting at org, else the nearest against-candle among the 3 bars before org, else org.
Bullish zone: top = high[ob], bot = min(low[ob], low[org]); bearish: top = max(high[ob], high[org]),
bot = low[ob]. Drawn from the earlier of ob/org.
FVG bars i-2, i, gap = low[i] - high[i-2] (bullish) or low[i-2] - high[i] (bearish) >= fvg_min * ATR[i].
sweep a bar wicks beyond an unswept swing (confirmed, at most `sweep_look` bars old) by >= sweep_min * ATR and
closes back inside: the lowest low (highest high) such swing is reported. Every swing wicked through is
marked taken whether or not the bar closed back inside.
mitigation (bars after the zone's creation bar), modes: 0 touch (wick reaches the near edge), 1 midpoint (wick reaches
the middle), 2 far-edge wick, 3 far-edge close. Midpoint is inclusive within a relative 1e-10.
touch a bar whose wick reaches the near edge of a live zone that was not inside on the bar before.
session optional: zones and sweeps are only created on bars whose open hour (server time) is inside [start, end).
Per bar the order is: mitigation/touch of older zones, sweeps, FVG, break + order block, then the swing `swing` bars back."""
import numpy as np
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 in_session(t, start, end):
hr = (int(t) // 3600) % 24
if start == end:
return True
return start <= hr < end if start < end else (hr >= start or hr < end)
def mitigated(z, mode, o, h, l, c):
"""Does this bar mitigate zone z?"""
top, bot = z["top"], z["bot"]
mid = (top + bot) / 2.0
tol = abs(mid) * 1e-10 # a wick exactly on the midpoint counts; (top+bot)/2 can be 1 ulp off in decimals
if z["dir"] > 0:
return {0: l <= top, 1: l <= mid + tol, 2: l <= bot, 3: c < bot}[mode]
return {0: h >= bot, 1: h >= mid - tol, 2: h >= top, 3: c > top}[mode]
def smc_zones(t, o, h, l, c, swing=3, atr=14, fvg_min=0.20, ob_mit=3, fvg_mit=2, sweep_look=60, sweep_min=0.10,
use_session=False, sess_start=7, sess_end=17):
n = len(c)
A = rma(true_range(h, l, c), atr)
nan = lambda: np.full(n, NAN)
out = {k: nan() for k in ("ob_bull_top", "ob_bull_bot", "ob_bear_top", "ob_bear_bot", "fvg_bull_top", "fvg_bull_bot",
"fvg_bear_top", "fvg_bear_bot", "struct", "level", "sweep_bull", "sweep_bear",
"mitigated", "touched", "active_bull", "active_bear")}
zones, swings, breaks, sweeps, live = [], [], [], [], []
newest = {1: None, -1: None} # newest swing per side (side +1 = high)
trend = 0
swept_at = {1: -1, -1: -1}
def is_swing(p, side):
for k in range(1, swing + 1):
if side > 0 and (h[p - k] >= h[p] or h[p + k] > h[p]):
return False
if side < 0 and (l[p - k] <= l[p] or l[p + k] < l[p]):
return False
return True
def far(i, d):
return l[i] if d > 0 else h[i]
def near(i, d):
return h[i] if d > 0 else l[i]
def against(i, d):
return c[i] < o[i] if d > 0 else c[i] > o[i]
for i in range(n - 1): # the last bar is still forming
ok_atr = not np.isnan(A[i]) and A[i] > 0
if ok_atr:
a = A[i]
sess = (not use_session) or in_session(t[i], sess_start, sess_end)
# 1. older zones: mitigated or touched
n_mit = n_touch = 0
for z in live:
if z["born"] >= i:
continue
mode = ob_mit if z["kind"] == "ob" else fvg_mit
if mitigated(z, mode, o[i], h[i], l[i], c[i]):
z["end"] = i
n_mit += 1
continue
d = z["dir"]
inside = (l[i] <= z["top"]) if d > 0 else (h[i] >= z["bot"])
if inside and not z["inside"]:
z["touches"] += 1
n_touch += 1
z["inside"] = inside
live[:] = [z for z in live if z["end"] is None]
out["mitigated"][i], out["touched"][i] = n_mit, n_touch
# 2. sweeps
for d in (1, -1):
side, best = -d, None
for s in reversed(swings):
if i - s["i"] > sweep_look:
break
if s["side"] != side or s["swept"]:
continue
if (far(i, d) - (s["p"] - d * sweep_min * a)) * d < 0:
s["swept"] = True
if (c[i] - s["p"]) * d > 0 and (best is None or s["p"] * d < best["p"] * d):
best = s
if best is not None:
swept_at[d] = i
if sess:
out["sweep_bull" if d > 0 else "sweep_bear"][i] = best["p"]
sweeps.append(dict(dir=d, level=best["p"], at=i, frm=best["i"]))
# 3. fair value gap
if i >= 2:
for d in (1, -1):
gap = (l[i] - h[i - 2]) if d > 0 else (l[i - 2] - h[i])
if gap >= fvg_min * a and sess:
top, bot = (l[i], h[i - 2]) if d > 0 else (l[i - 2], h[i])
zones.append(dict(kind="fvg", dir=d, top=top, bot=bot, t0=i - 1, born=i, end=None,
touches=0, inside=False))
live.append(zones[-1])
k = "fvg_bull" if d > 0 else "fvg_bear"
out[k + "_top"][i], out[k + "_bot"][i] = top, bot
# 4. structure break and order block
for d in (1, -1):
s = newest[d]
if s is None or s["broken"] or (c[i] - s["p"]) * d <= 0:
continue
s["broken"] = True
choch = trend == -d
trend = d
org = s["i"]
for j in range(s["i"], i + 1):
if (far(j, d) - far(org, d)) * d < 0:
org = j
ob = -1
if against(org, d):
ob = org
while ob + 1 < i and against(ob + 1, d):
ob += 1
else:
for j in range(org - 1, max(0, org - 3) - 1, -1):
if against(j, d):
ob = j
break
if ob < 0:
ob = org
if d > 0:
top, bot = h[ob], min(l[ob], l[org])
else:
top, bot = max(h[ob], h[org]), l[ob]
if sess:
zones.append(dict(kind="ob", dir=d, top=top, bot=bot, t0=min(ob, org), born=i, end=None,
touches=0, inside=False))
live.append(zones[-1])
k = "ob_bull" if d > 0 else "ob_bear"
out[k + "_top"][i], out[k + "_bot"][i] = top, bot
out["struct"][i] = d * (2 if choch else 1)
out["level"][i] = s["p"]
breaks.append(dict(dir=d, choch=choch, level=s["p"], frm=s["i"], at=i))
out["active_bull"][i] = sum(1 for z in live if z["dir"] > 0)
out["active_bear"][i] = sum(1 for z in live if z["dir"] < 0)
# 5. the swing `swing` bars back is known from the next bar on
p = i - swing
if p >= swing:
for side in (1, -1):
if is_swing(p, side):
s = dict(i=p, p=h[p] if side > 0 else l[p], side=side, swept=False, broken=False)
swings.append(s)
newest[side] = s
return out, zones, breaks, sweeps