| # SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 (see LICENSE)
|
| """Python twin of Harm.mqh: the same swings, pattern ratios and PRZ completion rule, step for step, so the indicators'
|
| output can be compared bar by bar (tests/check_parity.py). The rules are described in the README.
|
|
|
| from reference import Params, run
|
| events = run(high, low, close, Params())
|
| """
|
| from dataclasses import dataclass
|
|
|
| ABCD, GARTLEY, BAT = range(3)
|
| NAMES = ["ABCD", "Gartley", "Bat"]
|
| # b_lo, b_hi (AB/XA), c_lo, c_hi (BC/AB), d (key ratio), p_lo, p_hi (CD/BC at the key level, 0 = no check)
|
| RATIOS = [
|
| (0, 0, 0.382, 0.886, 1.0, 0, 0),
|
| (0.618, 0.618, 0.382, 0.886, 0.786, 1.13, 1.618),
|
| (0.382, 0.50, 0.382, 0.886, 0.886, 1.618, 2.618),
|
| ]
|
|
|
|
|
| @dataclass
|
| class Params:
|
| left: int = 10
|
| right: int = 5
|
| tol: float = 0.05
|
| prz: float = 0.03
|
| use: tuple = (True, True, True)
|
|
|
|
|
| def inside(v, lo, hi, tol):
|
| return lo * (1 - tol) <= v <= hi * (1 + tol)
|
|
|
|
|
| def match(sw, pr):
|
| """Patterns completed by the last swing (C) of `sw` [(type, price, bar)]: list of candidate dicts."""
|
| out = []
|
| if len(sw) < 3:
|
| return out
|
| ct, cp, cb = sw[-1]
|
| bt, bp, bb = sw[-2]
|
| at, ap, ab = sw[-3]
|
| bull = ct == 1 # C is a high: D below it
|
| s = 1.0 if bull else -1.0
|
| AB = abs(ap - bp)
|
| BC = abs(cp - bp)
|
| if AB <= 0 or BC <= 0:
|
| return out
|
| for pat in range(len(NAMES)):
|
| if not pr.use[pat]:
|
| continue
|
| b_lo, b_hi, c_lo, c_hi, d, p_lo, p_hi = RATIOS[pat]
|
| if pat == ABCD:
|
| if not inside(BC / AB, c_lo, c_hi, pr.tol):
|
| continue
|
| key = cp - s * AB
|
| scale = AB
|
| xp, xb = None, None
|
| else:
|
| if len(sw) < 4:
|
| continue
|
| xt, xp, xb = sw[-4]
|
| XA = abs(ap - xp)
|
| if XA <= 0 or not inside(AB / XA, b_lo, b_hi, pr.tol):
|
| continue
|
| if not inside(BC / AB, c_lo, c_hi, pr.tol):
|
| continue
|
| key = ap - s * d * XA
|
| if p_lo > 0 and not inside(abs(cp - key) / BC, p_lo, p_hi, pr.tol):
|
| continue
|
| scale = XA
|
| if s * (cp - key) <= 0:
|
| continue
|
| out.append(dict(pat=pat, dir=1 if bull else -1,
|
| near=key + s * pr.prz * scale, far=key - s * pr.prz * scale,
|
| x=(xb, xp), a=(ab, ap), b=(bb, bp), c=(cb, cp), ext=None, ext_bar=-1, worst=None))
|
| return out
|
|
|
|
|
| def run(high, low, close, pr: Params = Params()):
|
| """events: list of dicts in firing order; each has the bar it fired on, the points and the PRZ."""
|
| n = len(high)
|
| sw = [] # (type 1 high / -1 low, price, bar)
|
| cands = []
|
| events = []
|
| for t in range(n):
|
| h, l, c = high[t], low[t], close[t]
|
| # 1) live candidates take this bar
|
| alive = []
|
| for cd in cands:
|
| if step(cd, t, h, l, c, events):
|
| alive.append(cd)
|
| cands = alive
|
| # 2) pivot confirmed at p = t - right
|
| p = t - pr.right
|
| if p - pr.left < 0:
|
| continue
|
| is_h = all(high[p] > high[p - j] for j in range(1, pr.left + 1)) and \
|
| all(high[p] >= high[p + j] for j in range(1, pr.right + 1))
|
| is_l = all(low[p] < low[p - j] for j in range(1, pr.left + 1)) and \
|
| all(low[p] <= low[p + j] for j in range(1, pr.right + 1))
|
| if is_h and is_l:
|
| if not sw:
|
| continue
|
| is_h, is_l = sw[-1][0] == -1, sw[-1][0] == 1
|
| changed = False
|
| for typ, ok, px in ((1, is_h, high[p]), (-1, is_l, low[p])):
|
| if not ok:
|
| continue
|
| if sw and sw[-1][0] == typ:
|
| if (px > sw[-1][1]) if typ == 1 else (px < sw[-1][1]):
|
| sw[-1] = (typ, px, p)
|
| changed = True
|
| elif sw and ((typ == -1 and px >= sw[-1][1]) or (typ == 1 and px <= sw[-1][1])):
|
| pass # on the wrong side of the last swing
|
| else:
|
| sw.append((typ, px, p))
|
| if len(sw) > 8:
|
| sw.pop(0)
|
| changed = True
|
| if not changed:
|
| continue
|
| cands = match(sw, pr)
|
| # 3) new candidates catch up on the bars after C (C's pivot guarantees none of them is beyond C)
|
| alive = []
|
| for cd in cands:
|
| ok = True
|
| for j in range(cd["c"][0] + 1, t + 1):
|
| if not step(cd, j, high[j], low[j], close[j], events, fire_at=t):
|
| ok = False
|
| break
|
| if ok:
|
| alive.append(cd)
|
| cands = alive
|
| return events
|
|
|
|
|
| def step(cd, j, h, l, c, events, fire_at=None):
|
| """Feed bar j to a candidate. False when it is finished (dead, void or fired). `fire_at` = the bar an event is
|
| dated on during the catch-up (the confirmation bar, never earlier)."""
|
| s = cd["dir"]
|
| if (h > cd["c"][1]) if s > 0 else (l < cd["c"][1]):
|
| return False # beyond C: C moves, the candidate dies
|
| ext = l if s > 0 else h
|
| if cd["ext"] is None or ((ext < cd["ext"]) if s > 0 else (ext > cd["ext"])):
|
| cd["ext"], cd["ext_bar"] = ext, j
|
| if cd["worst"] is None or ((c < cd["worst"]) if s > 0 else (c > cd["worst"])):
|
| cd["worst"] = c
|
| if (cd["ext"] <= cd["near"]) if s > 0 else (cd["ext"] >= cd["near"]):
|
| if (cd["worst"] < cd["far"]) if s > 0 else (cd["worst"] > cd["far"]):
|
| return False # closed beyond the zone: void
|
| bar = j if fire_at is None else fire_at
|
| events.append(dict(bar=bar, dir=s, pat=cd["pat"], name=NAMES[cd["pat"]], x=cd["x"], a=cd["a"], b=cd["b"],
|
| c=cd["c"], d=(cd["ext_bar"], cd["ext"]), near=cd["near"], far=cd["far"]))
|
| return False
|
| return True
|