#!/usr/bin/env python3 """Sleeve B — naïef stijlreplicaat. Geen LLM, geen discretie. Protocol: docs/preregistratie-ab-stijlreplicaat.md score = 50% momentum (252d, laatste 21d eruit) + 50% ROE beide als percentielrang BINNEN de sector · top 28 · gelijk gewogen wekelijks (maandag), synthetisch, 3 bp kosten op de omzet python3 sleeve_b.py --dry # rekent + toont, schrijft niets python3 sleeve_b.py # berekent, hasht en pusht naar /api/decisions Publiceert VOOR de opening alleen de SHA-256 van de doellijst; na sluiting de lijst zelf. Zo is achteraf niet te schuiven met wat er besloten was. """ import argparse, hashlib, json, os, statistics as st, sys, urllib.request from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) CFG = json.load(open(os.path.expanduser("~/.quanthea/config.json"))) KEY, SEC = CFG["alpaca_paper_key_id"], CFG["alpaca_paper_secret"] DATA = CFG.get("alpaca_data_url", "https://data.alpaca.markets") HDR = {"APCA-API-KEY-ID": KEY, "APCA-API-SECRET-KEY": SEC} BASE = "https://quanthea.nl" TOKEN = CFG.get("admin_api_token") or CFG["app_secret_key"] UNIVERSE = os.path.join(HERE, "universe-ab-frozen.csv") STATE = os.path.expanduser("~/.quanthea/sleeve_b_state.json") N_HOLD = 28 # Dubbele aandelenklassen van dezelfde onderneming: houd alleen de best scorende. # Zonder deze regel selecteert de formule GOOG én GOOGL en zit Alphabet er dubbel in. DUAL_CLASS = [{"GOOG", "GOOGL"}, {"FOX", "FOXA"}, {"NWS", "NWSA"}] MOM_LOOKBACK, MOM_SKIP = 252, 21 COST_BPS = 3.0 def universe(): """Bevroren S&P 500-lijst met GICS-sector. Sector komt UIT deze lijst, niet uit yfinance: bevroren en publiek, dus geen drift tijdens de proef.""" out = {} for line in open(UNIVERSE): line = line.split("#")[0].strip() if not line or line.lower().startswith("symbol,"): continue parts = line.split(",", 1) if len(parts) == 2 and parts[0].strip(): out[parts[0].strip().upper()] = parts[1].strip() return out def bars(syms): """Dagelijkse slotkoersen. Alpaca geeft ZONDER `start` een lege respons, en pagineert via next_page_token — beide waren nodig om dit werkend te krijgen.""" start = (datetime.now(timezone.utc) - timedelta(days=560)).strftime("%Y-%m-%d") out = {} for i in range(0, len(syms), 100): chunk = ",".join(syms[i:i + 100]) token, pages = None, 0 while True: url = (f"{DATA}/v2/stocks/bars?symbols={chunk}&timeframe=1Day" f"&start={start}&limit=10000&adjustment=all&feed=iex") if token: url += f"&page_token={token}" try: d = json.load(urllib.request.urlopen( urllib.request.Request(url, headers=HDR), timeout=90)) except Exception as e: print(f" bars-blok {i//100} pagina {pages} faalde: {e}", file=sys.stderr) break for s, rows in (d.get("bars") or {}).items(): out.setdefault(s, []).extend(r["c"] for r in rows) token = d.get("next_page_token") pages += 1 if not token or pages > 40: break print(f" blok {i//100 + 1}: {pages} pagina's, {len(out)} namen totaal", flush=True) return out def roe(syms): """Return on equity via yfinance. Ontbrekend = uitgesloten, niet geraden.""" import yfinance as yf out = {} for i, s in enumerate(syms): try: r = yf.Ticker(s).info.get("returnOnEquity") if r is not None: out[s] = float(r) except Exception: pass if i % 100 == 99: print(f" ROE {i+1}/{len(syms)}…", flush=True) return out def pct_rank(vals): """Percentielrang 0-1 binnen een lijst; gelijke waarden krijgen dezelfde rang.""" order = sorted(vals.items(), key=lambda kv: kv[1]) n = len(order) return {k: (i / (n - 1) if n > 1 else 0.5) for i, (k, _) in enumerate(order)} def main(): ap = argparse.ArgumentParser() ap.add_argument("--dry", action="store_true") a = ap.parse_args() sect = universe() syms = sorted(sect) print(f"universum: {len(syms)} namen (bevroren), {len(set(sect.values()))} sectoren") px = bars(syms) mom = {} for s, c in px.items(): if len(c) >= MOM_LOOKBACK + 1: mom[s] = c[-MOM_SKIP - 1] / c[-MOM_LOOKBACK - 1] - 1 print(f"momentum berekend voor {len(mom)}") prof = roe(sorted(mom)) print(f"ROE beschikbaar voor {len(prof)}") # percentielrangen BINNEN de sector — anders meet je vooral de sectorrotatie score = {} for sector in set(sect.values()): names = [s for s in prof if sect[s] == sector] if len(names) < 3: continue rm = pct_rank({s: mom[s] for s in names}) rp = pct_rank({s: prof[s] for s in names}) for s in names: score[s] = 0.5 * rm[s] + 0.5 * rp[s] ranked = sorted(score, key=lambda s: -score[s]) top, taken = [], [] for s in ranked: grp = next((g for g in DUAL_CLASS if s in g), None) if grp and grp in taken: continue # zusteraandeel staat er al in if grp: taken.append(grp) top.append(s) if len(top) == N_HOLD: break w = round(1.0 / len(top), 6) target = {s: w for s in sorted(top)} payload = {"target": target, "n": len(top), "method": "50% mom(252-21) + 50% ROE, " "percentielrang binnen sector, top-28 gelijk gewogen", "protocol": "docs/preregistratie-ab-stijlreplicaat.md"} blob = json.dumps(target, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(blob.encode()).hexdigest() prev = {} try: prev = json.load(open(STATE)).get("target", {}) except Exception: pass turnover = sum(abs(target.get(s, 0) - prev.get(s, 0)) for s in set(target) | set(prev)) / 2 cost = turnover * COST_BPS / 10000 print(f"\ntop {len(top)}: {', '.join(sorted(top))}") print(f"sectoren: {len(set(sect[s] for s in top))}") print(f"turnover t.o.v. vorige week: {turnover*100:.1f}% → kosten {cost*10000:.2f} bp") print(f"SHA-256 van de doellijst: {digest}") if a.dry: print("\n(dry-run — niets weggeschreven)") return rec = {"kind": "ab_sleeve_b", "title": f"Sleeve B — stijlreplicaat {len(top)} namen", "thesis": f"Mechanisch, geen LLM. Hash {digest}. Turnover {turnover*100:.1f}%.", "target": ",".join(sorted(top)), "payload": {**payload, "sha256": digest, "turnover": turnover, "cost": cost}, "at": datetime.now(timezone.utc).isoformat()} req = urllib.request.Request(f"{BASE}/api/admin/decision", data=json.dumps(rec).encode(), headers={"Content-Type": "application/json", "X-Admin-Token": TOKEN}) try: urllib.request.urlopen(req, timeout=30) print("→ gepubliceerd op /api/decisions") except Exception as e: print(f"push faalde: {e}", file=sys.stderr) json.dump({"target": target, "sha256": digest, "at": rec["at"]}, open(STATE, "w")) if __name__ == "__main__": main()