80 lines
3.9 KiB
Python
# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 (see LICENSE)
| |||
"""Self-test of tests/reference.py on synthetic bars (no broker data needed).
| |||
| |||
python3 tests/check_reference.py [seeds] default 20 seeds, 3 years of H4 bars each
| |||
| |||
For every random walk and each source (UT Bot, SuperTrend, Range Filter) it checks: the last bar never carries a signal (it
| |||
is still forming); SuperTrend signals alternate buy, sell, buy; every trade enters at the open of the bar after a signal bar;
| |||
the stop distance is stop x ATR(14) of the signal bar; trades never overlap; a reversal exits and re-enters at the same open on
| |||
the other side; a stop exit is on the bar where the extreme touches the stop."""
| |||
import sys
| |||
from pathlib import Path
| |||
| |||
import numpy as np
| |||
import pandas as pd
| |||
| |||
sys.path.insert(0, str(Path(__file__).parent))
| |||
from reference import signals, run, atr14 # noqa: E402
| |||
| |||
SOURCES = {"ut": dict(key=2.0, atr=10, ema=0), "st": dict(atr=10, mult=2.0, ema=0), "rf": dict(period=100, mult=3.0)}
| |||
| |||
| |||
def synth(seed, years=3):
| |||
rng = np.random.default_rng(seed)
| |||
idx = pd.date_range("2021-01-01", periods=years * 365 * 6, freq="4h")
| |||
idx = idx[idx.dayofweek < 5]
| |||
drift = 0.15 * np.sin(np.arange(len(idx)) / 400.0)
| |||
close = 1800 + np.cumsum(rng.normal(drift, 6.0))
| |||
open_ = np.concatenate(([1800.0], close[:-1]))
| |||
span = np.abs(rng.normal(0, 3.0, len(idx)))
| |||
return pd.DataFrame({"open": open_, "high": np.maximum(open_, close) + span, "low": np.minimum(open_, close) - span,
| |||
"close": close, "spread": rng.choice([0.2, 0.3, 0.5], len(idx))}, index=idx)
| |||
| |||
| |||
def main(seeds):
| |||
problems, n_sig, n_tr = [], 0, 0
| |||
for seed in range(seeds):
| |||
b = synth(seed)
| |||
o, h, l, c = (b[k].to_numpy(float) for k in ("open", "high", "low", "close"))
| |||
atr = atr14(h, l, c)
| |||
for src, p in SOURCES.items():
| |||
sig = signals(src, p, o, h, l, c)
| |||
n_sig += int((sig != 0).sum())
| |||
if sig[-1] != 0:
| |||
problems.append(("signal on the last (forming) bar", src, seed))
| |||
if src == "st":
| |||
s = sig[sig != 0]
| |||
if len(s) > 1 and (s[1:] == s[:-1]).any():
| |||
problems.append(("SuperTrend signals do not alternate", src, seed))
| |||
tr = run(b, sig, 3.0)
| |||
n_tr += len(tr)
| |||
pos_idx = {t: k for k, t in enumerate(b.index)}
| |||
last_exit = None
| |||
for i, r in enumerate(tr.itertuples()):
| |||
k = pos_idx[r.entry_bar]
| |||
if sig[k - 1] != r.side:
| |||
problems.append(("entry not on the bar after a same-side signal", src, seed, str(r.entry_bar)))
| |||
if abs(r.dist - 3.0 * atr[k - 1]) > 1e-9:
| |||
problems.append(("stop distance is not 3 x ATR(14) of the signal bar", src, seed, str(r.entry_bar)))
| |||
if last_exit is not None and r.entry_bar < last_exit:
| |||
problems.append(("overlap", src, seed, str(r.entry_bar)))
| |||
if i > 0 and tr.iloc[i - 1].why == "reverse":
| |||
q = tr.iloc[i - 1]
| |||
if not (q.exit_bar == r.entry_bar and q.side == -r.side):
| |||
problems.append(("reversal not at the same open", src, seed, str(r.entry_bar)))
| |||
if r.why == "stop":
| |||
j = pos_idx[r.exit_bar]
| |||
touched = l[j] <= r.exit if r.side > 0 else h[j] + b.spread.iloc[j] >= r.exit
| |||
if not touched:
| |||
problems.append(("stop exit without a touch", src, seed, str(r.exit_bar)))
| |||
last_exit = r.exit_bar
| |||
print(f"seeds: {seeds}, signals: {n_sig}, trades: {n_tr}")
| |||
print(f"violations: {len(problems)}")
| |||
for p in problems[:10]:
| |||
print(" ", p)
| |||
print("RESULT:", "PASS" if not problems else "FAIL")
| |||
return 0 if not problems else 1
| |||
| |||
| |||
if __name__ == "__main__":
| |||
sys.exit(main(int(sys.argv[1]) if len(sys.argv) > 1 else 20))
|