"""Rotation lab: ground truth for 'major rotations' and a scoreboard any rule can be graded on. Hindsight is used ONLY to label, never inside a rule.""" import json, math, os, sys sys.path.insert(0, "/home/claude/rotmap/deploy") import rotmap as R ST = "/home/claude/rotmap/deploy/store" M = None def load(): global M if M is None: M = R.Model(json.load(open(ST + "/px.json")), json.load(open(ST + "/fund.json"))) return M def lr(M, b, i, n): a, z = M.idx[b][i], (M.idx[b][i + n] if i + n < M.N else None) return math.log(z / a) if a and z else None def alts_idx(M): """Equal weight of the six alt baskets, daily rebalanced.""" out, lvl = [None] * M.N, None for i in range(1, M.N): rs = [math.log(M.idx[b][i] / M.idx[b][i - 1]) for b in R.ALT_BASKETS if M.idx[b][i] and M.idx[b][i - 1]] if len(rs) >= 2: lvl = (lvl or 100.0) * math.exp(sum(rs) / len(rs)) out[i] = lvl return out def oracle(M, H=45, min_run=21, lo="2020-06-01"): """Best of CASH / BTC / ALTS over the next H days, runs shorter than min_run merged into their neighbour. Returns list of (label) per day and the run list [(start_i, end_i, label, edge_log)].""" A = alts_idx(M); M.idx["ALTS"] = A i0 = M.pos[lo]; lab = [None] * M.N for i in range(i0, M.N - H): c = {"CASH": 0.0, "BTC": lr(M, "BTC", i, H), "ALTS": lr(M, "ALTS", i, H)} c = {k: v for k, v in c.items() if v is not None} lab[i] = max(c, key=c.get) # runs runs, s = [], i0 for i in range(i0 + 1, M.N - H + 1): if i == M.N - H or lab[i] != lab[s]: runs.append([s, i - 1, lab[s]]); s = i changed = True while changed: changed = False for k, r in enumerate(runs): if r[1] - r[0] + 1 < min_run and len(runs) > 1: j = k - 1 if k > 0 else k + 1 if k > 0 and k + 1 < len(runs): # merge into the longer neighbour j = k - 1 if (runs[k-1][1]-runs[k-1][0]) >= (runs[k+1][1]-runs[k+1][0]) else k + 1 a, b = sorted((k, j)) runs[a] = [runs[a][0], runs[b][1], runs[j][2]]; del runs[b] changed = True; break # join equal neighbours k = 0 while k + 1 < len(runs): if runs[k][2] == runs[k + 1][2]: runs[k][1] = runs[k + 1][1]; del runs[k + 1]; changed = True else: k += 1 lab2 = [None] * M.N out = [] for s, e, l in runs: for i in range(s, e + 1): lab2[i] = l def gr(b): if b == "CASH": return 0.0 a, z = M.idx[b][s], M.idx[b][min(e + 1, M.N - 1)] return math.log(z / a) rets = {k: gr(k) for k in ("CASH", "BTC", "ALTS")} edge = rets[l] - max(v for k, v in rets.items() if k != l) out.append((s, e, l, edge, rets)) return lab2, out def bucket(w): if not w: return "CASH" alt = sum(v for k, v in w.items() if k not in ("BTC",)) return "ALTS" if alt >= 0.5 else "BTC" def score(M, rule, lo="2020-06-01", hi=None, H=45, label=None, runs=None, verbose=False): hi = hi or M.days[M.N - H - 1] i0, i1 = M.pos[lo], M.pos[hi] if label is None: label, runs = oracle(M, H) curve, log = M.backtest(rule, i0, i1) held = {M.pos[d]: bucket(w) for d, _, w in curve} days = [i for i in range(i0, i1 + 1) if label[i]] acc = sum(held[i] == label[i] for i in days) / len(days) # capture on daily returns: system vs oracle vs worst def dret(b, i): if b == "CASH": return 0.0 a, z = M.idx[b][i], M.idx[b][i + 1] return math.log(z / a) if a and z else 0.0 got = best = base = 0.0 for i in days[:-1]: opts = {k: dret(k, i) for k in ("CASH", "BTC", "ALTS")} got += opts[held[i]]; best += opts[label[i]]; base += sum(opts.values()) / 3 # event table for major runs ev = [] for s, e, l, edge, rets in runs: if s < i0 or e > i1 or edge < math.log(1.20): continue lag = next((k - s for k in range(s, e + 1) if held.get(k) == l), None) share = sum(held.get(k) == l for k in range(s, e + 1)) / (e - s + 1) ev.append((M.days[s], M.days[e], l, round(100 * (math.exp(edge) - 1)), lag, round(100 * share))) caught = sum(1 for x in ev if x[5] >= 50) eq = curve[-1][1] / curve[0][1] return {"acc": round(100 * acc), "capture": round(100 * (got - base) / (best - base)), "major": len(ev), "caught": caught, "med_lag": sorted([x[4] for x in ev if x[4] is not None])[len([x for x in ev if x[4] is not None]) // 2] if any(x[4] is not None for x in ev) else None, "switches": len(log), "ret": round(100 * (eq - 1)), "events": ev}