| """
|
| Is the START of the month special for metals, or is it just drift?
|
|
|
| Every trading-day-of-month position gets the same treatment: the 2-day window
|
| entered at the close of TDOM k-1 and exited at the close of TDOM k+1. If the
|
| first-2-days window is merely riding the 2016-26 metals rally, every position
|
| earns about the drift and TDOM 1-2 is not special. If it is a genuine turn-of-
|
| month effect, it should rank at or near the top of ~20 positions in BOTH IS and
|
| OOS, and beat the drift baseline by a margin a permutation test respects.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import sys
|
| import numpy as np
|
|
|
| sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else ".")
|
| import fx_screen as fs # noqa: E402
|
|
|
| SPLIT = np.datetime64("2016-01-01")
|
|
|
|
|
| def tdom(ts):
|
| """1-based trading-day-of-month for each bar."""
|
| mon = ts.astype("datetime64[D]").astype("datetime64[M]")
|
| out = np.ones(len(ts), int)
|
| for i in range(1, len(ts)):
|
| out[i] = 1 if mon[i] != mon[i - 1] else out[i - 1] + 1
|
| return out
|
|
|
|
|
| def profile(sym, width=2):
|
| d = fs.load(sym, "D1")
|
| ts, c = d["ts"], d["c"]
|
| k = tdom(ts)
|
| r = np.full(len(c), np.nan)
|
| r[width:] = (c[width:] - c[:-width]) / c[:-width] # window ENDING at bar i
|
| end_k = k # TDOM of the exit bar
|
| res = {}
|
| for pos in range(width, 21): # exit on TDOM pos -> window TDOM pos-1..pos
|
| m = (end_k == pos) & np.isfinite(r)
|
| res[pos] = (r[m & (ts < SPLIT)], r[m & (ts >= SPLIT)])
|
| return res, np.nanmean(r[ts < SPLIT]), np.nanmean(r[ts >= SPLIT])
|
|
|
|
|
| if __name__ == "__main__":
|
| rng = np.random.default_rng(0)
|
| for sym in sys.argv[1:] or ["XAUUSD", "XAGUSD", "XPTUSD", "XPDUSD", "XAUEUR"]:
|
| res, dis, doos = profile(sym)
|
| is_m = {p: v[0].mean() * 1e4 for p, v in res.items()}
|
| oo_m = {p: v[1].mean() * 1e4 for p, v in res.items()}
|
| rank_is = sorted(is_m, key=is_m.get, reverse=True).index(2) + 1
|
| rank_oo = sorted(oo_m, key=oo_m.get, reverse=True).index(2) + 1
|
| #--- permutation: how often does a RANDOM set of month-windows of the same
|
| #--- size beat the TDOM-1..2 excess over drift, OOS
|
| oos_all = np.concatenate([v[1] for v in res.values()])
|
| tom = res[2][1]
|
| exc = tom.mean() - oos_all.mean()
|
| perm = np.mean([rng.choice(oos_all, len(tom), replace=False).mean() - oos_all.mean() >= exc
|
| for _ in range(5000)])
|
| print(f"{sym}: TDOM1-2 window IS {is_m[2]:+6.1f} bp (rank {rank_is}/19, all-window mean {dis * 1e4:+.1f})"
|
| f" OOS {oo_m[2]:+6.1f} bp (rank {rank_oo}/19, all-window mean {doos * 1e4:+.1f})"
|
| f" OOS excess {exc * 1e4:+.1f} bp, permutation p = {perm:.4f}")
|
| top = sorted(oo_m.items(), key=lambda x: -x[1])[:4]
|
| print(f" OOS top windows (exit TDOM: bp): {[(p, round(v, 1)) for p, v in top]}")
|