harmonic-signal-alerts/tests/reference.py
Allan Niñal Allan Niñal ee30110f8d Harmonic Signal Alerts: free MT5 and MT4 indicator source, Python reference, tests and README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:03:02 +08:00
154 lines
5.9 KiB
Python
# 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