90 lines
4.4 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, 4 years of hourly bars each
| |||
| |||
Per random walk, on the trades the reference returns: one position at a time (no overlap); every entry is at an H4 bar
| |||
open; a stop exit is at or beyond the stop and never inside the bar's range the wrong way; an entry's stop distance is
| |||
3 x ATR(14) of the signal bar; a reversal exits and re-enters at the same open on the other side; every signal that is
| |||
not ignored (same side as the open trade) while flat or opposite leads to a trade; and the signal rule never fires when
| |||
Supertrend or the daily EMA disagree."""
| |||
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 run, signals, rma, true_range, supertrend_dir, daily_ema_dir, ut_line # noqa: E402
| |||
| |||
| |||
def synth(seed, years=4):
| |||
rng = np.random.default_rng(seed)
| |||
idx = pd.date_range("2020-01-01", periods=years * 365 * 24, freq="h")
| |||
idx = idx[idx.dayofweek < 5]
| |||
drift = 0.02 * np.sin(np.arange(len(idx)) / 900.0)
| |||
close = 1800 + np.cumsum(rng.normal(drift, 2.0))
| |||
open_ = np.concatenate(([1800.0], close[:-1]))
| |||
span = np.abs(rng.normal(0, 1.2, len(idx)))
| |||
h1 = pd.DataFrame({"open": open_, "high": np.maximum(open_, close) + span, "low": np.minimum(open_, close) - span,
| |||
"close": close}, index=idx)
| |||
spread = pd.Series(rng.choice([0.15, 0.25, 0.4], len(idx)), index=idx)
| |||
return h1, spread
| |||
| |||
| |||
def bars(h1, rule):
| |||
g = h1.resample(rule, label="left", closed="left")
| |||
return pd.DataFrame({"open": g.open.first(), "high": g.high.max(), "low": g.low.min(), "close": g.close.last()}).dropna()
| |||
| |||
| |||
def main(seeds):
| |||
problems, n_trades, n_signals = [], 0, 0
| |||
for seed in range(seeds):
| |||
h1, spread = synth(seed)
| |||
h4, d1 = bars(h1, "4h"), bars(h1, "1D")
| |||
sp4 = spread.resample("4h", label="left", closed="left").mean().reindex(h4.index).ffill()
| |||
sig, atr = signals(h4, d1)
| |||
n_signals += int((sig != 0).sum())
| |||
# the signal rule never fires against Supertrend or the daily EMA
| |||
o, h, l, c = (h4[k].to_numpy(float) for k in ("open", "high", "low", "close"))
| |||
st = supertrend_dir(h, l, c, rma(true_range(h, l, c), 10), 3.0)
| |||
for j in np.flatnonzero(sig.to_numpy() != 0):
| |||
if st[j] != sig.iloc[j]:
| |||
problems.append(("signal against Supertrend", seed, str(h4.index[j])))
| |||
tr = run(h4, d1, sp4)
| |||
n_trades += len(tr)
| |||
if tr.empty:
| |||
continue
| |||
opens = set(h4.index)
| |||
last_exit = None
| |||
for i, r in enumerate(tr.itertuples()):
| |||
if r.entry_time not in opens:
| |||
problems.append(("entry not at a bar open", seed, str(r.entry_time)))
| |||
if last_exit is not None and r.entry_time < last_exit:
| |||
problems.append(("overlap", seed, str(r.entry_time)))
| |||
if r.exit_time < r.entry_time:
| |||
problems.append(("exit before entry", seed, str(r.entry_time)))
| |||
if i > 0 and tr.iloc[i - 1].why == "reversed":
| |||
p = tr.iloc[i - 1]
| |||
if not (p.exit_time == r.entry_time and p["dir"] == -r.dir):
| |||
problems.append(("reversal not at the same open on the other side", seed, str(r.entry_time)))
| |||
if r.why == "stop":
| |||
bar = h4.loc[r.exit_time]
| |||
if r.dir > 0 and not (bar.low <= r.exit + 1e-9 or r.exit <= bar.open + 1e-9):
| |||
problems.append(("buy stop exit outside the bar", seed, str(r.exit_time)))
| |||
last_exit = r.exit_time
| |||
# entry stop distance = 3 x ATR(14) of the signal bar
| |||
for r in tr.itertuples():
| |||
j = h4.index.get_loc(r.entry_time) - 1
| |||
if abs(r.dist - 3.0 * atr.iloc[j]) > 1e-9:
| |||
problems.append(("entry stop distance is not 3 x ATR(14) of the signal bar", seed, str(r.entry_time)))
| |||
print(f"seeds: {seeds}, signals: {n_signals}, trades: {n_trades}")
| |||
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))
|