| """
|
| Forex & metals screen - implements research/FX_PLAN.md exactly, nothing more.
|
|
|
| Each family returns per-instrument trade lists; `report()` scores them against
|
| the pre-registered bar: OOS t >= 2 after spread, same variant positive IS,
|
| breadth >= half the class, beats a matched random control.
|
|
|
| python fx_screen.py T1|T2|R1|S1|M1
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import sys
|
| import numpy as np
|
|
|
| sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else ".")
|
| import backtest as bt # noqa: E402
|
|
|
| COMMON = bt.COMMON
|
| SPLIT = np.datetime64("2016-01-01")
|
|
|
| MAJORS = ["EURUSD", "GBPUSD", "USDJPY", "USDCHF", "USDCAD", "AUDUSD", "NZDUSD"]
|
| CROSSES = ["EURJPY", "EURGBP", "EURCHF", "EURAUD", "EURCAD", "EURNZD", "GBPJPY", "GBPCHF",
|
| "GBPAUD", "GBPCAD", "GBPNZD", "AUDJPY", "AUDCAD", "AUDCHF", "AUDNZD", "NZDJPY",
|
| "NZDCAD", "NZDCHF", "CADJPY", "CADCHF", "CHFJPY"]
|
| METALS = ["XAUUSD", "XAGUSD", "XPTUSD", "XPDUSD", "XAUEUR"]
|
| FX = MAJORS + CROSSES
|
| ALL = FX + METALS
|
| #--- "twinned" crosses: economically linked pairs where R1 has a reason to work
|
| TWINS = ["EURGBP", "EURCHF", "AUDNZD", "AUDCAD", "NZDCAD", "CADCHF", "EURNZD", "GBPCHF"]
|
|
|
|
|
| def load(sym, tf):
|
| """fx_<SYM>_PERIOD_<TF>.csv via backtest.load (same spread/point handling)."""
|
| old = bt.COMMON
|
| d = None
|
| try:
|
| path = rf"{COMMON}\fx_{sym}_PERIOD_{tf}.csv"
|
| raw = np.genfromtxt(path, delimiter=",", skip_header=1, dtype=str, encoding="ansi")
|
| ts = np.array([f"{r[0][:10].replace('.', '-')}T{r[0][11:]}" for r in raw], dtype="datetime64[s]")
|
| o, h, l, c = (raw[:, i].astype(float) for i in (1, 2, 3, 4))
|
| spread_pts = raw[:, 6].astype(float)
|
| digits = max(len(s.split(".")[1]) if "." in s else 0 for s in raw[:50, 4])
|
| point = 10.0 ** (-digits)
|
| #--- a zero spread in the bar data is a missing reading, not a free
|
| #--- trade: fall back to the symbol's median non-zero spread
|
| nz = spread_pts[spread_pts > 0]
|
| fill = np.median(nz) if len(nz) else 0.0
|
| spread_pts = np.where(spread_pts > 0, spread_pts, fill)
|
| d = dict(ts=ts, o=o, h=h, l=l, c=c, cost=spread_pts * point, point=point, symbol=sym)
|
| finally:
|
| bt.COMMON = old
|
| return d
|
|
|
|
|
| def trade(d, i_in, i_out, side, px_in, px_out):
|
| net = side * (px_out - px_in) - d["cost"][i_in]
|
| return dict(i=i_in, j=i_out, t=d["ts"][i_in], side=side, ret=net / px_in,
|
| gross=side * (px_out - px_in) / px_in, bars=i_out - i_in + 1)
|
|
|
|
|
| # ------------------------------------------------------------------ families
|
| def fam_T1(d, L):
|
| """Close vs SMA(L): long above, short below; flip at next open; 3xATR20 stop,
|
| after a stop stay flat until the next cross."""
|
| o, h, l, c = d["o"], d["h"], d["l"], d["c"]
|
| m = bt.sma(c, L)
|
| a = bt.atr(h, l, c, 20)
|
| sig = np.where(c > m, 1, np.where(c < m, -1, 0))
|
| tr, pos, i_in, stop, px_in, blocked = [], 0, 0, 0.0, 0.0, 0
|
| for i in range(L, len(c) - 1):
|
| if pos != 0:
|
| hit = (pos > 0 and l[i] <= stop) or (pos < 0 and h[i] >= stop)
|
| if hit:
|
| tr.append(trade(d, i_in, i, pos, px_in, stop))
|
| blocked, pos = pos, 0
|
| elif sig[i] == -pos:
|
| tr.append(trade(d, i_in, i + 1, pos, px_in, o[i + 1]))
|
| pos = 0
|
| if blocked and sig[i] == -blocked:
|
| blocked = 0
|
| if pos == 0 and sig[i] != 0 and sig[i] != blocked and np.isfinite(a[i]):
|
| pos, i_in, px_in = sig[i], i + 1, o[i + 1]
|
| stop = px_in - pos * 3.0 * a[i]
|
| return tr
|
|
|
|
|
| def fam_T2(d, N):
|
| """N-day channel breakout on the close, exit on the N/2 opposite channel,
|
| 2xATR20 stop. One position at a time."""
|
| o, h, l, c = d["o"], d["h"], d["l"], d["c"]
|
| a = bt.atr(h, l, c, 20)
|
| X = max(N // 2, 2)
|
| tr, pos, i_in, stop, px_in = [], 0, 0, 0.0, 0.0
|
| for i in range(N, len(c) - 1):
|
| if pos != 0:
|
| if (pos > 0 and l[i] <= stop) or (pos < 0 and h[i] >= stop):
|
| tr.append(trade(d, i_in, i, pos, px_in, stop))
|
| pos = 0
|
| continue
|
| ex = (pos > 0 and c[i] < l[i - X:i].min()) or (pos < 0 and c[i] > h[i - X:i].max())
|
| if ex:
|
| tr.append(trade(d, i_in, i + 1, pos, px_in, o[i + 1]))
|
| pos = 0
|
| continue
|
| if pos == 0 and np.isfinite(a[i]):
|
| if c[i] > h[i - N:i].max():
|
| pos = 1
|
| elif c[i] < l[i - N:i].min():
|
| pos = -1
|
| if pos:
|
| i_in, px_in = i + 1, o[i + 1]
|
| stop = px_in - pos * 2.0 * a[i]
|
| return tr
|
|
|
|
|
| def fam_R1(d, z_th):
|
| """Two-sided z(20) reversion, exit at SMA20 or 10 bars, 3xATR14 stop."""
|
| c = d["c"]
|
| m = bt.sma(c, 20)
|
| s = bt.rolling_std(c, 20)
|
| z = (c - m) / np.where(s > 0, s, np.nan)
|
| out = []
|
| for side, e in ((1, z <= -z_th), (-1, z >= z_th)):
|
| for t in bt.simulate(d, np.nan_to_num(e, nan=0).astype(bool), side=side, exit_ma=m,
|
| max_bars=10, stop_atr=3.0):
|
| out.append(dict(i=t["entry_i"], j=t["exit_i"], t=t["t"], side=side, ret=t["ret"],
|
| gross=t["gross"], bars=t["bars"]))
|
| return out
|
|
|
|
|
| # -------------------------------------------------------------------- scoring
|
| def control(d, trades, seed=0):
|
| """Random entries with the SAME side mix and holding lengths, no stop."""
|
| rng = np.random.default_rng(seed)
|
| n = len(d["c"])
|
| out = []
|
| for t in trades:
|
| b = int(t["bars"])
|
| i = rng.integers(1, max(2, n - b - 1))
|
| j = min(i + b - 1, n - 1)
|
| px_in, px_out = d["o"][i], d["c"][j]
|
| out.append((t["side"] * (px_out - px_in) - d["cost"][i]) / px_in)
|
| return np.array(out)
|
|
|
|
|
| def tstat(x):
|
| x = np.asarray(x, float)
|
| if len(x) < 5 or x.std(ddof=1) == 0:
|
| return np.nan
|
| return x.mean() / (x.std(ddof=1) / np.sqrt(len(x)))
|
|
|
|
|
| def score(sym, d, trades):
|
| if not trades:
|
| return None
|
| ret = np.array([t["ret"] for t in trades])
|
| ist = np.array([t["t"] < SPLIT for t in trades])
|
| ctrl = np.concatenate([control(d, [t for t in trades if t["t"] >= SPLIT], s) for s in range(5)])
|
| yrs = (d["ts"][-1] - d["ts"][0]) / np.timedelta64(365, "D")
|
| return dict(sym=sym, n=len(ret), permo=len(ret) / (yrs * 12),
|
| is_n=int(ist.sum()), is_bp=ret[ist].mean() * 1e4 if ist.any() else np.nan,
|
| is_t=tstat(ret[ist]),
|
| oos_n=int((~ist).sum()), oos_bp=ret[~ist].mean() * 1e4 if (~ist).any() else np.nan,
|
| oos_t=tstat(ret[~ist]), ctrl_bp=ctrl.mean() * 1e4 if len(ctrl) else np.nan,
|
| long_share=np.mean([t["side"] > 0 for t in trades]),
|
| span=f"{str(d['ts'][0])[:4]}-{str(d['ts'][-1])[:4]}")
|
|
|
|
|
| def report(title, rows, trials):
|
| rows = [r for r in rows if r]
|
| print(f"\n=== {title} (trials in this family so far: {trials}) ===")
|
| print(f"{'sym':<8}{'data':>10}{'n':>6}{'/mo':>5}{'IS bp':>8}{'IS t':>6}{'OOS n':>6}{'OOS bp':>8}"
|
| f"{'OOS t':>7}{'ctrl':>7}{'long%':>6} verdict")
|
| passed = 0
|
| for r in rows:
|
| ok = (r["oos_t"] >= 2 and r["is_bp"] > 0 and r["oos_bp"] > r["ctrl_bp"])
|
| passed += ok
|
| print(f"{r['sym']:<8}{r['span']:>10}{r['n']:>6}{r['permo']:>5.1f}{r['is_bp']:>8.1f}{r['is_t']:>6.2f}"
|
| f"{r['oos_n']:>6}{r['oos_bp']:>8.1f}{r['oos_t']:>7.2f}{r['ctrl_bp']:>7.1f}{r['long_share']:>6.0%}"
|
| f" {'PASS' if ok else ''}")
|
| pos = sum(1 for r in rows if r["oos_bp"] > 0)
|
| print(f"-> {passed}/{len(rows)} pass the per-symbol bar; OOS positive on {pos}/{len(rows)} "
|
| f"(breadth bar: >= {len(rows) / 2:.0f})")
|
| return passed, pos
|
|
|
|
|
| def pooled(rows_trades, label):
|
| """Pool trade returns across instruments, IS vs OOS, for a family variant."""
|
| is_r, oos_r = [], []
|
| for trades in rows_trades:
|
| for t in trades:
|
| (is_r if t["t"] < SPLIT else oos_r).append(t["ret"])
|
| print(f" pooled {label}: IS {np.mean(is_r) * 1e4 if is_r else np.nan:+.1f} bp (t {tstat(is_r):.2f}, n {len(is_r)})"
|
| f" OOS {np.mean(oos_r) * 1e4 if oos_r else np.nan:+.1f} bp (t {tstat(oos_r):.2f}, n {len(oos_r)})")
|
| return np.mean(is_r) if is_r else np.nan
|
|
|
|
|
| def run_family(fam, variants, syms, tf):
|
| fn = {"T1": fam_T1, "T2": fam_T2, "R1": fam_R1}[fam]
|
| data = {}
|
| for s in syms:
|
| try:
|
| data[s] = load(s, tf)
|
| except OSError:
|
| pass
|
| print(f"\n##### {fam} on {tf}: {len(data)} instruments, variants {variants}")
|
| #--- choose the variant on POOLED IS expectancy only
|
| best, best_is, per_var = None, -np.inf, {}
|
| for v in variants:
|
| tl = {s: fn(d, v) for s, d in data.items()}
|
| per_var[v] = tl
|
| m = pooled(tl.values(), f"{fam}({v})")
|
| if np.isfinite(m) and m > best_is:
|
| best, best_is = v, m
|
| print(f" -> variant chosen on IS: {best}")
|
| trials = len(variants) * len(data)
|
| rows = [score(s, data[s], per_var[best][s]) for s in data]
|
| report(f"{fam}({best}) {tf} - chosen on IS, OOS shown once", rows, trials)
|
| return per_var[best], data
|
|
|
|
|
| if __name__ == "__main__":
|
| fam = sys.argv[1] if len(sys.argv) > 1 else "T1"
|
| if fam == "T1":
|
| run_family("T1", [50, 100, 200], ALL, "D1")
|
| elif fam == "T2":
|
| run_family("T2", [20, 55, 100], ALL, "D1")
|
| elif fam == "R1":
|
| for tf in ("D1", "H4"):
|
| run_family("R1", [1.5, 2.0], TWINS, tf)
|
| run_family("R1", [1.5, 2.0], MAJORS, tf) # expected to FAIL - control class
|