research: ORB (STOP), overnight-only momentum (STOP), Alpaca paper forward test
- orb_stocks_in_play: 5-min bounds -70%/yr (conservative) vs +71%/yr (optimistic); the optimistic bound breaks even at 3.6 bp slippage a side. - overnight_only: the momentum sleeve's names earn their return overnight (Sharpe 1.57) and nothing intraday; held close->open only, net Sharpe 1.29-1.42 at 1 bp a side, below the 1.5 gate. - overnight_paper.py + systemd unit: MOC buy / MOO sell of the top 10 on the Alpaca paper account from 2026-10-12, with a fills-vs-prints report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -43,6 +43,12 @@ uv run python agent_review.py candidates # after the US close: top-50 pack
|
|||||||
uv run python agent_review.py submit data/agent/us/draft_<as_of>.json # before 16:00 New York of the next session
|
uv run python agent_review.py submit data/agent/us/draft_<as_of>.json # before 16:00 New York of the next session
|
||||||
uv run python agent_review.py evaluate # score IC, veto outcomes, book vs overnight_recovery_top10
|
uv run python agent_review.py evaluate # score IC, veto outcomes, book vs overnight_recovery_top10
|
||||||
|
|
||||||
|
# Overnight-only momentum book on Alpaca PAPER (MOC buy top 10 at close-20 min, MOO sell at open-20 min; refuses non-paper endpoints)
|
||||||
|
uv run python overnight_paper.py signal # next session's top 10, no orders
|
||||||
|
uv run python overnight_paper.py buy|sell --dry-run # show the orders it would send
|
||||||
|
uv run python overnight_paper.py report # paper fills vs official open/close prints, nightly returns
|
||||||
|
uv run python overnight_paper.py daemon # the scheduler (service quant-trading-overnight-paper)
|
||||||
|
|
||||||
# Precompute the strategy page's backtests (writes data/precomputed/)
|
# Precompute the strategy page's backtests (writes data/precomputed/)
|
||||||
uv run python precompute_backtest.py
|
uv run python precompute_backtest.py
|
||||||
|
|
||||||
@@ -90,7 +96,8 @@ PYTHONPATH=. uv run python -m research.lab.turn_of_month # turn-of-mo
|
|||||||
PYTHONPATH=. uv run python -m research.lab.short_vol [--no-proxy] # short VIX futures with a VIX/VIX3M filter, 2006-26 (quick check; STOP 2026-10-08)
|
PYTHONPATH=. uv run python -m research.lab.short_vol [--no-proxy] # short VIX futures with a VIX/VIX3M filter, 2006-26 (quick check; STOP 2026-10-08)
|
||||||
PYTHONPATH=. uv run python -m research.lab.fetch_alpaca etfs|stocks|status # Alpaca free SIP intraday bars 2016+ (1-min ETFs, 5-min liquid S&P 1500; keys from .env, never logged)
|
PYTHONPATH=. uv run python -m research.lab.fetch_alpaca etfs|stocks|status # Alpaca free SIP intraday bars 2016+ (1-min ETFs, 5-min liquid S&P 1500; keys from .env, never logged)
|
||||||
PYTHONPATH=. uv run python -m research.lab.intraday_momentum # SPY noise-area intraday momentum replication + 5 ETFs (preregistered; STOP 2026-10-09)
|
PYTHONPATH=. uv run python -m research.lab.intraday_momentum # SPY noise-area intraday momentum replication + 5 ETFs (preregistered; STOP 2026-10-09)
|
||||||
PYTHONPATH=. uv run python -m research.lab.orb_stocks_in_play # 5-min opening-range breakout on the top-20 stocks in play (preregistered)
|
PYTHONPATH=. uv run python -m research.lab.orb_stocks_in_play # 5-min opening-range breakout on the top-20 stocks in play (preregistered; STOP 2026-10-09: 5-min bounds -70%/yr vs +71%/yr, optimistic breakeven slippage 3.6 bp/side)
|
||||||
|
PYTHONPATH=. uv run python -m research.lab.overnight_only # momentum names held close->open only, MOC/MOO costs (preregistered; STOP 2026-10-09: net Sharpe 1.29-1.42 < 1.5, but the sleeve's whole return is overnight)
|
||||||
uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs)
|
uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs)
|
||||||
uv run python -m research.lab.fetch_us_fundamentals --universe small # the same for S&P 600 (IJR)
|
uv run python -m research.lab.fetch_us_fundamentals --universe small # the same for S&P 600 (IJR)
|
||||||
PYTHONPATH=. uv run python -m research.lab.midcap_sleeve [--only h1..h4] [--universe small] # S&P 400 sleeves vs IJH; S&P 600 replication vs IJR (all REJECT 2026-10-08)
|
PYTHONPATH=. uv run python -m research.lab.midcap_sleeve [--only h1..h4] [--universe small] # S&P 400 sleeves vs IJH; S&P 600 replication vs IJR (all REJECT 2026-10-08)
|
||||||
@@ -190,7 +197,7 @@ The comparison writes its fixed budget and acceptance rule before running and re
|
|||||||
|
|
||||||
## Server Deployment
|
## Server Deployment
|
||||||
|
|
||||||
Three `systemd --user` services, defined in `deploy/systemd/`, run from `/home/gahow/projects/quant-trading`:
|
Four `systemd --user` services, defined in `deploy/systemd/`, run from `/home/gahow/projects/quant-trading`:
|
||||||
|
|
||||||
| Service | Script | Role |
|
| Service | Script | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -200,6 +207,7 @@ Three `systemd --user` services, defined in `deploy/systemd/`, run from `/home/g
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp deploy/systemd/*.service ~/.config/systemd/user/
|
cp deploy/systemd/*.service ~/.config/systemd/user/
|
||||||
|
| `quant-trading-overnight-paper` | `deploy/start-overnight-paper.sh` | `overnight_paper.py daemon`: overnight-only momentum on the Alpaca paper account (from 2026-10-12) |
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now quant-trading-monitor quant-trading-api quant-trading-vite
|
systemctl --user enable --now quant-trading-monitor quant-trading-api quant-trading-vite
|
||||||
journalctl --user -u quant-trading-monitor -f
|
journalctl --user -u quant-trading-monitor -f
|
||||||
|
|||||||
13
deploy/start-overnight-paper.sh
Executable file
13
deploy/start-overnight-paper.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Overnight-only momentum book on Alpaca's PAPER account (overnight_paper.py daemon).
|
||||||
|
# Started by the quant-trading-overnight-paper.service systemd --user unit.
|
||||||
|
# Keys are read from the repo-root .env by the script itself; Alpaca is reached directly (no proxy).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
export PATH="/home/gahow/.local/bin:$PATH"
|
||||||
|
|
||||||
|
exec uv run python overnight_paper.py daemon
|
||||||
14
deploy/systemd/quant-trading-overnight-paper.service
Normal file
14
deploy/systemd/quant-trading-overnight-paper.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Overnight-only momentum book on Alpaca paper (MOC buy, MOO sell)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/gahow/projects/quant-trading
|
||||||
|
ExecStart=/home/gahow/projects/quant-trading/deploy/start-overnight-paper.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
462
overnight_paper.py
Normal file
462
overnight_paper.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
Forward test of the overnight-only momentum book on Alpaca's PAPER account
|
||||||
|
(research: research/lab/overnight_only.py). Each session it buys the top 10 of
|
||||||
|
overnight_recovery's score with market-on-close orders and sells every
|
||||||
|
position with market-on-open orders at the next session, so the book is flat
|
||||||
|
during the day. The test measures what the backtest cannot: fills against the
|
||||||
|
official close/open prints, and the forward record.
|
||||||
|
|
||||||
|
uv run python overnight_paper.py signal # today's list, no orders
|
||||||
|
uv run python overnight_paper.py buy [--dry-run] # MOC orders; between close-25 and close-11 min
|
||||||
|
uv run python overnight_paper.py sell [--dry-run] # MOO sells; between 19:00 ET and open-2 min
|
||||||
|
uv run python overnight_paper.py report # fills vs official prints, nightly returns
|
||||||
|
uv run python overnight_paper.py daemon # runs sell / buy / report on Alpaca's calendar
|
||||||
|
|
||||||
|
Rules (as in the study's sleeve_score candidate):
|
||||||
|
score strategies.robust_momentum.signal_overnight_recovery (12-1 month
|
||||||
|
overnight momentum + distance from the 126-day low, percentile
|
||||||
|
blend) on Alpaca SIP daily bars (split/dividend adjusted) through
|
||||||
|
the previous session, current S&P 500 members
|
||||||
|
book top 10, equal weight, whole shares (Alpaca accepts only whole-share
|
||||||
|
MOC/MOO orders), 1x the account equity, no leverage
|
||||||
|
orders buy: market, time_in_force "cls", sent at close - 20 min (Alpaca
|
||||||
|
rejects MOC orders after 15:50 ET); sell: market, "opg", sent at
|
||||||
|
open - 20 min. A position left over from a failed sell is resized
|
||||||
|
to the new target at the close.
|
||||||
|
Keys come from ALPACA_API_ENDPOINT / ALPACA_API_KEY / ALPACA_API_SECRET in the
|
||||||
|
environment or .env; they are never printed. The script refuses any endpoint
|
||||||
|
that is not paper-api.alpaca.markets.
|
||||||
|
|
||||||
|
Boundary: Alpaca's paper engine simulates fills from live quotes; it does not
|
||||||
|
reproduce the exchange auctions or market impact. The report therefore scores
|
||||||
|
both the paper fills and the official open/close prints of the same names.
|
||||||
|
|
||||||
|
State and logs: data/overnight_paper/ (orders.jsonl, signals/<date>.json,
|
||||||
|
state.json, fills.csv, nights.csv).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from strategies.robust_momentum import signal_overnight_recovery
|
||||||
|
|
||||||
|
OUT = os.path.join("data", "overnight_paper")
|
||||||
|
DATA_URL = "https://data.alpaca.markets/v2"
|
||||||
|
PAPER_HOST = "paper-api.alpaca.markets"
|
||||||
|
NY = ZoneInfo("America/New_York")
|
||||||
|
TOP_N = 10
|
||||||
|
HISTORY_DAYS = 450 # calendar days of daily bars (score needs ~280 sessions)
|
||||||
|
BUY_BEFORE_CLOSE = timedelta(minutes=20)
|
||||||
|
BUY_CUTOFF = timedelta(minutes=11) # Alpaca: MOC orders close at 15:50 ET
|
||||||
|
SELL_BEFORE_OPEN = timedelta(minutes=20)
|
||||||
|
SELL_CUTOFF = timedelta(minutes=2) # Alpaca: MOO orders close at 09:28 ET
|
||||||
|
REPORT_AFTER_OPEN = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alpaca client (paper only; keys never printed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _env() -> dict[str, str]:
|
||||||
|
env = dict(os.environ)
|
||||||
|
if os.path.exists(".env"):
|
||||||
|
for line in open(".env"):
|
||||||
|
if "=" in line and not line.lstrip().startswith("#"):
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
env.setdefault(k.strip(), v.strip().strip("'\""))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
class Alpaca:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
env = _env()
|
||||||
|
base = env["ALPACA_API_ENDPOINT"].rstrip("/")
|
||||||
|
if PAPER_HOST not in base:
|
||||||
|
raise SystemExit("ALPACA_API_ENDPOINT is not the paper API; refusing to trade")
|
||||||
|
self.base = base if base.endswith("/v2") else base + "/v2"
|
||||||
|
self.s = requests.Session()
|
||||||
|
self.s.headers.update({"APCA-API-KEY-ID": env["ALPACA_API_KEY"],
|
||||||
|
"APCA-API-SECRET-KEY": env["ALPACA_API_SECRET"]})
|
||||||
|
|
||||||
|
def _req(self, method: str, url: str, **kw) -> requests.Response:
|
||||||
|
for attempt in range(6):
|
||||||
|
try:
|
||||||
|
r = self.s.request(method, url, timeout=30, **kw)
|
||||||
|
except requests.RequestException:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
if r.status_code == 429 or r.status_code >= 500:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
return r
|
||||||
|
raise RuntimeError(f"{method} {url.split('/v2')[-1]}: retries exhausted")
|
||||||
|
|
||||||
|
def get(self, path: str, data: bool = False, **params):
|
||||||
|
r = self._req("GET", (DATA_URL if data else self.base) + path, params=params)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"GET {path}: HTTP {r.status_code} {r.text[:200]}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def order(self, payload: dict) -> dict:
|
||||||
|
r = self._req("POST", self.base + "/orders", json=payload)
|
||||||
|
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {"text": r.text}
|
||||||
|
return {"http": r.status_code, **body}
|
||||||
|
|
||||||
|
def cancel(self, order_id: str) -> int:
|
||||||
|
return self._req("DELETE", f"{self.base}/orders/{order_id}").status_code
|
||||||
|
|
||||||
|
|
||||||
|
def alpaca_symbol(yahoo: str) -> str:
|
||||||
|
return yahoo.replace("-", ".")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# calendar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sessions(cl: Alpaca, start: date, end: date) -> pd.DataFrame:
|
||||||
|
rows = cl.get("/calendar", start=start.isoformat(), end=end.isoformat())
|
||||||
|
out = []
|
||||||
|
for c in rows:
|
||||||
|
d = date.fromisoformat(c["date"])
|
||||||
|
o = datetime.combine(d, datetime.strptime(c["open"], "%H:%M").time(), NY)
|
||||||
|
k = datetime.combine(d, datetime.strptime(c["close"], "%H:%M").time(), NY)
|
||||||
|
out.append({"date": d, "open": o, "close": k})
|
||||||
|
return pd.DataFrame(out).set_index("date")
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# signal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sp500() -> list[str]:
|
||||||
|
try:
|
||||||
|
from universe import get_sp500
|
||||||
|
return get_sp500()
|
||||||
|
except Exception as exc: # noqa: BLE001 - Wikipedia unreachable: last cached list
|
||||||
|
with open(os.path.join("data", "universe_sp500.json")) as fh:
|
||||||
|
cached = json.load(fh)
|
||||||
|
print(f"S&P 500 list from cache dated {cached.get('date')} ({type(exc).__name__})", flush=True)
|
||||||
|
return cached["tickers"]
|
||||||
|
|
||||||
|
|
||||||
|
def _data_end(end: date) -> str:
|
||||||
|
"""The free plan refuses SIP data from the last 15 minutes; a bare date counts as that whole day."""
|
||||||
|
stop = min(datetime.combine(end, datetime.strptime("23:59", "%H:%M").time(), NY), # daily bars are stamped 00:00 ET
|
||||||
|
now_utc() - timedelta(minutes=20))
|
||||||
|
return stop.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def daily_bars(cl: Alpaca, symbols: list[str], start: date, end: date) -> dict[str, pd.DataFrame]:
|
||||||
|
"""Adjusted SIP daily open/close, one column per Yahoo-spelled symbol."""
|
||||||
|
rows = []
|
||||||
|
amap = {alpaca_symbol(s): s for s in symbols}
|
||||||
|
names = list(amap)
|
||||||
|
for i in range(0, len(names), 100):
|
||||||
|
params = {"symbols": ",".join(names[i:i + 100]), "timeframe": "1Day", "start": start.isoformat(),
|
||||||
|
"end": _data_end(end), "adjustment": "all", "feed": "sip", "limit": 10000}
|
||||||
|
while True:
|
||||||
|
j = cl.get("/stocks/bars", data=True, **params)
|
||||||
|
for sym, bars in (j.get("bars") or {}).items():
|
||||||
|
for b in bars:
|
||||||
|
rows.append((amap[sym], b["t"][:10], b["o"], b["c"]))
|
||||||
|
tok = j.get("next_page_token")
|
||||||
|
if not tok:
|
||||||
|
break
|
||||||
|
params["page_token"] = tok
|
||||||
|
df = pd.DataFrame(rows, columns=["sym", "date", "open", "close"])
|
||||||
|
df["date"] = pd.to_datetime(df["date"])
|
||||||
|
return {f: df.pivot(index="date", columns="sym", values=f).sort_index() for f in ("open", "close")}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_signal(cl: Alpaca, session: date) -> dict:
|
||||||
|
"""Top 10 for the close of ``session`` from bars through the previous session."""
|
||||||
|
cal = sessions(cl, session - timedelta(days=10), session)
|
||||||
|
prev = [d for d in cal.index if d < session]
|
||||||
|
if not prev:
|
||||||
|
raise RuntimeError(f"no session before {session}")
|
||||||
|
last = prev[-1]
|
||||||
|
syms = sp500()
|
||||||
|
bars = daily_bars(cl, syms, session - timedelta(days=HISTORY_DAYS), last)
|
||||||
|
if bars["close"].index[-1].date() != last:
|
||||||
|
raise RuntimeError(f"daily bars end {bars['close'].index[-1].date()}, expected {last}")
|
||||||
|
score = signal_overnight_recovery({"open": bars["open"], "close": bars["close"]})
|
||||||
|
row = score.iloc[-1].dropna().sort_values(ascending=False, kind="mergesort")
|
||||||
|
top = list(row.index[:TOP_N])
|
||||||
|
return {"session": session.isoformat(), "bars_through": last.isoformat(), "universe": len(syms),
|
||||||
|
"scored": int(row.size), "top": top, "scores": {s: round(float(row[s]), 4) for s in top},
|
||||||
|
"prev_close": {s: float(bars["close"][s].iloc[-1]) for s in top}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# state / logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _state() -> dict:
|
||||||
|
path = os.path.join(OUT, "state.json")
|
||||||
|
return json.load(open(path)) if os.path.exists(path) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(st: dict) -> None:
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
tmp = os.path.join(OUT, ".state.json.tmp")
|
||||||
|
json.dump(st, open(tmp, "w"), indent=1)
|
||||||
|
os.replace(tmp, os.path.join(OUT, "state.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_order(kind: str, payload: dict, resp: dict) -> None:
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
keep = {k: resp.get(k) for k in ("http", "id", "client_order_id", "status", "symbol", "qty", "side",
|
||||||
|
"time_in_force", "submitted_at", "message", "text")}
|
||||||
|
with open(os.path.join(OUT, "orders.jsonl"), "a") as fh:
|
||||||
|
fh.write(json.dumps({"logged_at": now_utc().isoformat(), "kind": kind, "payload": payload,
|
||||||
|
"response": keep}) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _submit(cl: Alpaca, kind: str, payload: dict, dry: bool) -> None:
|
||||||
|
tag = f"{payload['side']:4s} {payload['qty']:>6s} {payload['symbol']:6s} {payload['time_in_force']}"
|
||||||
|
if dry:
|
||||||
|
print(f" [dry-run] {tag}", flush=True)
|
||||||
|
return
|
||||||
|
resp = cl.order(payload)
|
||||||
|
_log_order(kind, payload, resp)
|
||||||
|
ok = resp["http"] in (200, 201)
|
||||||
|
dup = resp["http"] == 422 and "client_order_id" in json.dumps(resp).lower()
|
||||||
|
print(f" {tag} -> {'ok ' + str(resp.get('status')) if ok else 'already sent' if dup else 'HTTP ' + str(resp['http']) + ' ' + str(resp.get('message', resp.get('text', '')))[:120]}",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# actions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def positions(cl: Alpaca) -> dict[str, int]:
|
||||||
|
return {p["symbol"]: int(float(p["qty"])) for p in cl.get("/positions")}
|
||||||
|
|
||||||
|
|
||||||
|
def open_orders(cl: Alpaca) -> list[dict]:
|
||||||
|
return cl.get("/orders", status="open", limit=500)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_prices(cl: Alpaca, symbols: list[str]) -> dict[str, float]:
|
||||||
|
"""Last trade on the free real-time feed (IEX); sizing only."""
|
||||||
|
j = cl.get("/stocks/trades/latest", data=True, symbols=",".join(alpaca_symbol(s) for s in symbols), feed="iex")
|
||||||
|
return {s: float(j["trades"][alpaca_symbol(s)]["p"]) for s in symbols if alpaca_symbol(s) in j.get("trades", {})}
|
||||||
|
|
||||||
|
|
||||||
|
def buy(cl: Alpaca, dry: bool = False, force: bool = False) -> None:
|
||||||
|
today = datetime.now(NY).date()
|
||||||
|
cal = sessions(cl, today, today)
|
||||||
|
if today not in cal.index:
|
||||||
|
print(f"{today}: no session", flush=True)
|
||||||
|
return
|
||||||
|
close = cal.loc[today, "close"]
|
||||||
|
if not force and not (close - BUY_BEFORE_CLOSE - timedelta(minutes=5) <= now_utc() <= close - BUY_CUTOFF):
|
||||||
|
print(f"{today}: outside the MOC window (close {close:%H:%M} ET); use --force for a dry check", flush=True)
|
||||||
|
if not dry:
|
||||||
|
return
|
||||||
|
st = _state()
|
||||||
|
if st.get("bought") == today.isoformat() and not dry:
|
||||||
|
print(f"{today}: MOC orders already sent", flush=True)
|
||||||
|
return
|
||||||
|
sig = compute_signal(cl, today)
|
||||||
|
os.makedirs(os.path.join(OUT, "signals"), exist_ok=True)
|
||||||
|
json.dump(sig, open(os.path.join(OUT, "signals", f"{today}.json"), "w"), indent=1)
|
||||||
|
acct = cl.get("/account")
|
||||||
|
equity = float(acct["equity"])
|
||||||
|
px = latest_prices(cl, sig["top"])
|
||||||
|
held = positions(cl)
|
||||||
|
pending = {o["symbol"] for o in open_orders(cl) if o["time_in_force"] == "cls"}
|
||||||
|
target = {}
|
||||||
|
for s in sig["top"]:
|
||||||
|
p = px.get(s) or sig["prev_close"][s]
|
||||||
|
target[alpaca_symbol(s)] = math.floor(equity / TOP_N / p)
|
||||||
|
print(f"{today}: equity {equity:,.0f}, bars through {sig['bars_through']}, top {sig['top']}", flush=True)
|
||||||
|
for sym in sorted(set(target) | set(held)):
|
||||||
|
if sym in pending:
|
||||||
|
print(f" {sym}: MOC order already open", flush=True)
|
||||||
|
continue
|
||||||
|
delta = target.get(sym, 0) - held.get(sym, 0)
|
||||||
|
if delta == 0:
|
||||||
|
continue
|
||||||
|
payload = {"symbol": sym, "qty": str(abs(delta)), "side": "buy" if delta > 0 else "sell",
|
||||||
|
"type": "market", "time_in_force": "cls", "client_order_id": f"onb-{today}-{sym}"}
|
||||||
|
_submit(cl, "moc", payload, dry)
|
||||||
|
if not dry:
|
||||||
|
st["bought"] = today.isoformat()
|
||||||
|
_save_state(st)
|
||||||
|
|
||||||
|
|
||||||
|
def sell(cl: Alpaca, dry: bool = False) -> None:
|
||||||
|
now = now_utc()
|
||||||
|
cal = sessions(cl, (now - timedelta(days=1)).date(), (now + timedelta(days=10)).date())
|
||||||
|
nxt = cal[cal["open"] > now]
|
||||||
|
if nxt.empty:
|
||||||
|
print("no upcoming session", flush=True)
|
||||||
|
return
|
||||||
|
session, opn = nxt.index[0], nxt["open"].iloc[0]
|
||||||
|
if not dry and now > opn - SELL_CUTOFF:
|
||||||
|
print(f"{session}: too late for MOO orders", flush=True)
|
||||||
|
return
|
||||||
|
st = _state()
|
||||||
|
if st.get("sold") == session.isoformat() and not dry:
|
||||||
|
print(f"{session}: MOO orders already sent", flush=True)
|
||||||
|
return
|
||||||
|
held = {s: q for s, q in positions(cl).items() if q > 0}
|
||||||
|
pending = {o["symbol"] for o in open_orders(cl) if o["time_in_force"] == "opg" and o["side"] == "sell"}
|
||||||
|
print(f"{session}: MOO sells for {len(held)} positions", flush=True)
|
||||||
|
for sym, q in sorted(held.items()):
|
||||||
|
if sym in pending:
|
||||||
|
print(f" {sym}: MOO order already open", flush=True)
|
||||||
|
continue
|
||||||
|
payload = {"symbol": sym, "qty": str(q), "side": "sell", "type": "market", "time_in_force": "opg",
|
||||||
|
"client_order_id": f"ons-{session}-{sym}"}
|
||||||
|
_submit(cl, "moo", payload, dry)
|
||||||
|
if not dry:
|
||||||
|
st["sold"] = session.isoformat()
|
||||||
|
_save_state(st)
|
||||||
|
|
||||||
|
|
||||||
|
def report(cl: Alpaca) -> None:
|
||||||
|
"""Fills against the official prints (Alpaca SIP daily open/close, unadjusted) and nightly returns."""
|
||||||
|
orders, after = [], (now_utc() - timedelta(days=400)).isoformat()
|
||||||
|
while True:
|
||||||
|
page = cl.get("/orders", status="closed", limit=500, direction="asc", after=after)
|
||||||
|
orders += page
|
||||||
|
if len(page) < 500:
|
||||||
|
break
|
||||||
|
after = page[-1]["submitted_at"]
|
||||||
|
rows = []
|
||||||
|
for o in orders:
|
||||||
|
cid = o.get("client_order_id", "")
|
||||||
|
if not cid.startswith(("onb-", "ons-")) or o.get("filled_avg_price") is None:
|
||||||
|
continue
|
||||||
|
rows.append({"session": cid[4:14], "symbol": o["symbol"], "side": o["side"], "tif": o["time_in_force"],
|
||||||
|
"qty": float(o["filled_qty"]), "fill": float(o["filled_avg_price"]),
|
||||||
|
"filled_at": o["filled_at"]})
|
||||||
|
if not rows:
|
||||||
|
print("no fills yet", flush=True)
|
||||||
|
return
|
||||||
|
F = pd.DataFrame(rows)
|
||||||
|
d0 = date.fromisoformat(F["session"].min())
|
||||||
|
d1 = date.fromisoformat(F["session"].max())
|
||||||
|
rows = []
|
||||||
|
syms = sorted(F["symbol"].unique())
|
||||||
|
for i in range(0, len(syms), 100):
|
||||||
|
params = {"symbols": ",".join(syms[i:i + 100]), "timeframe": "1Day", "start": d0.isoformat(),
|
||||||
|
"end": _data_end(d1), "adjustment": "raw", "feed": "sip", "limit": 10000}
|
||||||
|
while True:
|
||||||
|
j = cl.get("/stocks/bars", data=True, **params)
|
||||||
|
for sym, bars in (j.get("bars") or {}).items():
|
||||||
|
rows += [(sym, b["t"][:10], b["o"], b["c"]) for b in bars]
|
||||||
|
if not j.get("next_page_token"):
|
||||||
|
break
|
||||||
|
params["page_token"] = j["next_page_token"]
|
||||||
|
P = pd.DataFrame(rows, columns=["symbol", "session", "open", "close"])
|
||||||
|
F = F.merge(P, on=["symbol", "session"], how="left")
|
||||||
|
F["ref"] = F["close"].where(F["tif"] == "cls", F["open"])
|
||||||
|
# cost in bp: positive = worse than the official print
|
||||||
|
sign = F["side"].map({"buy": 1, "sell": -1})
|
||||||
|
F["slip_bp"] = sign * (F["fill"] / F["ref"] - 1) * 1e4
|
||||||
|
F.to_csv(os.path.join(OUT, "fills.csv"), index=False)
|
||||||
|
b = F[F["tif"] == "cls"]
|
||||||
|
s = F[F["tif"] == "opg"]
|
||||||
|
print(f"fills {len(F)}: MOC slip {b['slip_bp'].mean():.2f} bp (median {b['slip_bp'].median():.2f}), "
|
||||||
|
f"MOO slip {s['slip_bp'].mean():.2f} bp (median {s['slip_bp'].median():.2f})", flush=True)
|
||||||
|
# nights: buy session d (close) -> sell next session (open)
|
||||||
|
nights = []
|
||||||
|
for d, g in b.groupby("session"):
|
||||||
|
later = s[s["session"] > d]
|
||||||
|
if later.empty:
|
||||||
|
continue
|
||||||
|
nd = later["session"].min()
|
||||||
|
sg = later[later["session"] == nd].set_index("symbol")
|
||||||
|
bg = g.set_index("symbol")
|
||||||
|
both = bg.index.intersection(sg.index)
|
||||||
|
if both.empty:
|
||||||
|
continue
|
||||||
|
cost = (bg.loc[both, "qty"] * bg.loc[both, "fill"]).sum()
|
||||||
|
pnl = (sg.loc[both, "qty"] * sg.loc[both, "fill"] - bg.loc[both, "qty"] * bg.loc[both, "fill"]).sum()
|
||||||
|
model = (sg.loc[both, "open"] / bg.loc[both, "close"] - 1).mean()
|
||||||
|
nights.append({"buy_session": d, "sell_session": nd, "names": len(both),
|
||||||
|
"paper_ret": pnl / cost, "print_ret": model})
|
||||||
|
if nights:
|
||||||
|
N = pd.DataFrame(nights)
|
||||||
|
N.to_csv(os.path.join(OUT, "nights.csv"), index=False)
|
||||||
|
print(N.tail(10).to_string(index=False, float_format=lambda x: f"{x:.4f}"), flush=True)
|
||||||
|
print(f"nights {len(N)}: paper mean {N['paper_ret'].mean()*1e4:.1f} bp/night, "
|
||||||
|
f"prints mean {N['print_ret'].mean()*1e4:.1f} bp/night", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# daemon
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def daemon() -> None:
|
||||||
|
cl = Alpaca()
|
||||||
|
print(f"{now_utc():%Y-%m-%d %H:%M} UTC daemon started (paper account)", flush=True)
|
||||||
|
done: set[tuple[str, str]] = set()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = now_utc()
|
||||||
|
cal = sessions(cl, (now - timedelta(days=1)).date(), (now + timedelta(days=10)).date())
|
||||||
|
for d, row in cal.iterrows():
|
||||||
|
key = d.isoformat()
|
||||||
|
sell_at, buy_at = row["open"] - SELL_BEFORE_OPEN, row["close"] - BUY_BEFORE_CLOSE
|
||||||
|
if ("sell", key) not in done and sell_at <= now < row["open"] - SELL_CUTOFF:
|
||||||
|
sell(cl)
|
||||||
|
done.add(("sell", key))
|
||||||
|
if ("buy", key) not in done and buy_at <= now < row["close"] - BUY_CUTOFF:
|
||||||
|
buy(cl)
|
||||||
|
done.add(("buy", key))
|
||||||
|
if ("report", key) not in done and row["open"] + REPORT_AFTER_OPEN <= now < row["close"]:
|
||||||
|
report(cl)
|
||||||
|
done.add(("report", key))
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep the loop alive; the next tick retries
|
||||||
|
print(f"{now_utc():%Y-%m-%d %H:%M} UTC error: {type(exc).__name__}: {str(exc)[:300]}", flush=True)
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("what", choices=["signal", "buy", "sell", "report", "daemon"])
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--force", action="store_true", help="buy: ignore the time window (still honours --dry-run)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.what == "daemon":
|
||||||
|
daemon()
|
||||||
|
return
|
||||||
|
cl = Alpaca()
|
||||||
|
if a.what == "signal":
|
||||||
|
today = datetime.now(NY).date()
|
||||||
|
cal = sessions(cl, today, today + timedelta(days=10))
|
||||||
|
nxt = cal[cal["close"] > now_utc()].index[0]
|
||||||
|
sig = compute_signal(cl, nxt)
|
||||||
|
print(json.dumps(sig, indent=1))
|
||||||
|
elif a.what == "buy":
|
||||||
|
if a.force and not a.dry_run:
|
||||||
|
sys.exit("--force is only for --dry-run checks")
|
||||||
|
buy(cl, dry=a.dry_run, force=a.force)
|
||||||
|
elif a.what == "sell":
|
||||||
|
sell(cl, dry=a.dry_run)
|
||||||
|
else:
|
||||||
|
report(cl)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -31,6 +31,27 @@ Deviations, fixed before the run:
|
|||||||
The rule is written to prereg.json before the run; the script refuses to
|
The rule is written to prereg.json before the run; the script refuses to
|
||||||
overwrite results.json. 2016-2023 is the paper's own sample; 2024-01 onward
|
overwrite results.json. 2016-2023 is the paper's own sample; 2024-01 onward
|
||||||
is after it.
|
is after it.
|
||||||
|
|
||||||
|
The fetch was pre-filtered to names that passed the filters on at least one
|
||||||
|
member day (fetch_alpaca.universe, liquid=True). The daily filters above
|
||||||
|
still apply.
|
||||||
|
|
||||||
|
Result (2026-10-09): STOP. The conservative bound fails all gates; the
|
||||||
|
optimistic bound fails O3 (stress).
|
||||||
|
- Data: 1,686 files, 356,908 eligible name-days, 42,807 trades.
|
||||||
|
- Conservative (a stop inside the entry bar counts as hit): -70.5%/yr,
|
||||||
|
Sharpe -7.5, win rate 8%.
|
||||||
|
- Optimistic (no stop inside the entry bar):
|
||||||
|
- Commission only: 71.5%/yr, Sharpe 2.14, MaxDD -29%. Paper-span
|
||||||
|
Sharpe 2.19, 2024+ 1.98, halves 2.32 / 1.97.
|
||||||
|
- +5 bp per side: Sharpe -0.86.
|
||||||
|
- A 10%-ATR stop sits inside a typical 5-minute range, so bar resolution
|
||||||
|
decides the sign.
|
||||||
|
- Even the optimistic bound breaks even at only 3.6 bp of slippage per
|
||||||
|
side on top of commission (1 bp: Sharpe 1.54; 2 bp: 0.94; 3 bp: 0.34),
|
||||||
|
at 3.2x average gross leverage.
|
||||||
|
- Stop-order entries on breakouts of stocks in play rarely fill within
|
||||||
|
a few bp, so 1-minute bars (RESOLVE) would not change the decision.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
284
research/lab/overnight_only.py
Normal file
284
research/lab/overnight_only.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
Hold the momentum sleeve's names only overnight (buy at the close, sell at the
|
||||||
|
next open) and stay flat during the day. Intraday trading is allowed since
|
||||||
|
2026-10-09, so an overnight-only book is tradable with market-on-close and
|
||||||
|
market-on-open orders, and it leaves the capital free for an intraday strategy
|
||||||
|
during the session.
|
||||||
|
|
||||||
|
PYTHONPATH=. uv run python -m research.lab.overnight_only
|
||||||
|
|
||||||
|
Background: Lou, Polk & Skouras, "A tug of war: Overnight versus intraday
|
||||||
|
expected returns" (JFE 2019) find that momentum profits accrue overnight and
|
||||||
|
partly reverse intraday. overnight_recovery_top10 ranks on 12-1 month
|
||||||
|
overnight returns, so its own return may sit mostly in the overnight leg.
|
||||||
|
|
||||||
|
Rules (one book per candidate, equal weight, rebuilt every night):
|
||||||
|
night t from the close of t-1 to the open of t
|
||||||
|
selection top 10 by the candidate score read at the close of t-2 (one day
|
||||||
|
of slack so the MOC order can be sent before the close of t-1),
|
||||||
|
PIT S&P 500 members on t-2
|
||||||
|
return open_t / close_{t-1} - 1 per name (Yahoo adjusted bars, so
|
||||||
|
dividends and splits net out); a missing open falls back to
|
||||||
|
close_t
|
||||||
|
costs every night trades the whole book twice: c per side, baseline
|
||||||
|
c = 1 bp (auction fills, $0 commission, SEC fee ~0.3 bp on
|
||||||
|
sells), stress c = 3 bp
|
||||||
|
cash earns nothing; Sharpe = mean / sd of the book's own returns
|
||||||
|
|
||||||
|
Candidates:
|
||||||
|
sleeve_score overnight_mom (12-1) + rec_126 percentile blend, the live
|
||||||
|
sleeve's score
|
||||||
|
overnight_12_1 overnight_mom alone
|
||||||
|
overnight_1m last month's overnight return (LPS: overnight returns
|
||||||
|
persist at short horizons)
|
||||||
|
|
||||||
|
Reported only: the overnight / intraday split of the sleeve's names held for
|
||||||
|
10 days (the live cadence, no hysteresis), and the equal-weight
|
||||||
|
member universe held overnight only.
|
||||||
|
|
||||||
|
Open-price fidelity (N5): Yahoo opens against the 09:30 bar of the Alpaca 5-min
|
||||||
|
SIP files on disk at run time (2016+), on all name-days and on the name-days
|
||||||
|
the candidates select. If N5 fails the verdict is DATA.
|
||||||
|
|
||||||
|
Every period here was examined before (the sleeve's score was chosen on this
|
||||||
|
panel). 2019+ is after the LPS publication, not independent OOS.
|
||||||
|
|
||||||
|
The rule is written to prereg.json before the run; the script refuses to
|
||||||
|
overwrite results.json.
|
||||||
|
|
||||||
|
Result (2026-10-09): STOP. No candidate reaches N1 (full-span net Sharpe 1.5).
|
||||||
|
N5 passes: on 288 names, 2016+, Yahoo and Alpaca opens give overnight returns
|
||||||
|
with corr 0.96 on all name-days and 0.996 on selected ones; the mean
|
||||||
|
difference is 0.03-0.3 bp.
|
||||||
|
|
||||||
|
candidate (net, 1 bp) CAGR Sharpe halves 2019+ 3 bp MaxDD
|
||||||
|
sleeve_score 30.4% 1.29 1.34 / 1.24 1.17 0.84 -31%
|
||||||
|
overnight_12_1 38.0% 1.42 1.59 / 1.24 1.16 1.02 -31%
|
||||||
|
overnight_1m 25.9% 1.08 1.47 / 0.53 0.54 0.66 -33%
|
||||||
|
|
||||||
|
Reported only:
|
||||||
|
- The live sleeve's names held 10 days (gross) earn the whole return
|
||||||
|
overnight: overnight leg 13.7 bp/day, Sharpe 1.57 (halves 1.62 / 1.50,
|
||||||
|
2019+ 1.43); intraday leg -0.8 bp/day, Sharpe -0.07; close to close
|
||||||
|
Sharpe 0.87.
|
||||||
|
- Nightly re-selection adds nothing over the 10-day list (13.5 vs 13.7
|
||||||
|
bp/day). The equal-weight universe earns 3.5 bp/day overnight (Sharpe 0.73).
|
||||||
|
- Costs decide the result: 2 sides a night at 1 bp take 2 bp of the 13.5
|
||||||
|
bp/day gross. Breakeven is about 6.7 bp a side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from research.lab.factor_scan import RESULTS, load
|
||||||
|
from research.lab.factors import overnight_mom, overnight_mom_1, rec_126
|
||||||
|
from research.lab.us_sweep import _blend
|
||||||
|
|
||||||
|
OUT = os.path.join(RESULTS, "overnight_only")
|
||||||
|
BARS = os.path.join("data", "research", "intraday", "alpaca", "5Min")
|
||||||
|
SPAN = ("2006-10-02", "2026-09-24")
|
||||||
|
HALVES = [("2006-10-02", "2016-12-30"), ("2017-01-03", "2026-09-24")]
|
||||||
|
POST_PUB = ("2019-01-01", "2026-09-24")
|
||||||
|
TOP_N, HOLD_DAYS = 10, 10
|
||||||
|
COST_BASE, COST_STRESS = 0.0001, 0.0003
|
||||||
|
|
||||||
|
PREREG = {
|
||||||
|
"question": "Does holding the momentum sleeve's names only overnight (MOC buy, MOO sell) earn a net "
|
||||||
|
"Sharpe high enough to be a stand-alone stream that frees intraday capital?",
|
||||||
|
"rules": "top 10 equal weight by score at close t-2, held close t-1 -> open t, rebuilt nightly; "
|
||||||
|
"costs 2 x c per night on the whole book, c = 1 bp base / 3 bp stress; no cash interest",
|
||||||
|
"candidates": ["sleeve_score", "overnight_12_1", "overnight_1m"],
|
||||||
|
"span": SPAN, "halves": HALVES, "post_publication": POST_PUB,
|
||||||
|
"gates": {"N1": "full-span net Sharpe (1 bp) >= 1.5",
|
||||||
|
"N2": "net Sharpe (1 bp) >= 1.2 in both halves",
|
||||||
|
"N3": "net Sharpe (1 bp) >= 1.0 in 2019-01 .. 2026-09",
|
||||||
|
"N4": "full-span net Sharpe at 3 bp/side >= 1.0",
|
||||||
|
"N5": "open fidelity vs Alpaca 09:30 bar (2016+): corr of overnight returns >= 0.9 on all "
|
||||||
|
"name-days and |mean diff| <= 3 bp on selected name-days"},
|
||||||
|
"decision": {"CONTINUE": "a candidate passes N1-N4 and N5 passes -> check MOC/MOO auction fills on Alpaca "
|
||||||
|
"intraday data and the combination with an intraday stream on the same capital",
|
||||||
|
"DATA": "N5 fails -> rebuild overnight returns from Alpaca opens before any verdict",
|
||||||
|
"STOP": "otherwise"},
|
||||||
|
"multiplicity": "3 candidates; a lone pass by one is weaker evidence than the sleeve score passing",
|
||||||
|
"data_status": "all periods previously examined; 2019+ is post-publication, not independent OOS",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def describe(r: pd.Series) -> dict:
|
||||||
|
r = r.dropna()
|
||||||
|
eq = (1 + r).cumprod()
|
||||||
|
yrs = len(r) / 252
|
||||||
|
return {"cagr": float(eq.iloc[-1] ** (1 / yrs) - 1), "vol": float(r.std() * np.sqrt(252)),
|
||||||
|
"sharpe": float(r.mean() / r.std() * np.sqrt(252)), "mdd": float((eq / eq.cummax() - 1).min()),
|
||||||
|
"mean_bp_per_day": float(r.mean() * 1e4)}
|
||||||
|
|
||||||
|
|
||||||
|
def legs(p) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
|
"""Per name-day overnight (open_t / close_{t-1}) and intraday (close_t / open_t) returns."""
|
||||||
|
c = p.close.ffill()
|
||||||
|
o = p.open.where(p.open.notna(), p.close)
|
||||||
|
return o / c.shift(1) - 1, p.close / o - 1
|
||||||
|
|
||||||
|
|
||||||
|
def top_mask(score: pd.DataFrame, n: int) -> pd.DataFrame:
|
||||||
|
rk = score.rank(axis=1, ascending=False, method="first")
|
||||||
|
return rk <= n
|
||||||
|
|
||||||
|
|
||||||
|
def nightly(sel: pd.DataFrame, on: pd.DataFrame) -> pd.Series:
|
||||||
|
"""Equal-weight overnight return of the selected names (selection already aligned to night t)."""
|
||||||
|
x = on.where(sel)
|
||||||
|
return x.mean(axis=1).where(sel.sum(axis=1) > 0)
|
||||||
|
|
||||||
|
|
||||||
|
def held_split(score: pd.DataFrame, on: pd.DataFrame, intra: pd.DataFrame) -> dict:
|
||||||
|
"""Sleeve names held 10 days (refresh at close t-1 with score through t-2), daily reset to equal
|
||||||
|
weight, averaged over the 10 phases: overnight leg, intraday leg, close-to-close."""
|
||||||
|
rows = {"overnight": [], "intraday": [], "close_to_close": []}
|
||||||
|
sel_daily = top_mask(score.shift(2), TOP_N)
|
||||||
|
idx = np.arange(len(score))
|
||||||
|
for off in range(HOLD_DAYS):
|
||||||
|
sel = sel_daily.astype(float)
|
||||||
|
sel.loc[(idx % HOLD_DAYS) != off] = np.nan
|
||||||
|
sel = sel.ffill().fillna(0.0).astype(bool)
|
||||||
|
o = on.where(sel).mean(axis=1)
|
||||||
|
i = intra.where(sel).mean(axis=1)
|
||||||
|
cc = ((1 + on) * (1 + intra) - 1).where(sel).mean(axis=1)
|
||||||
|
rows["overnight"].append(o); rows["intraday"].append(i); rows["close_to_close"].append(cc)
|
||||||
|
return {k: pd.concat(v, axis=1).mean(axis=1) for k, v in rows.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def alpaca_opens(columns) -> tuple[pd.DataFrame, pd.DataFrame, int]:
|
||||||
|
"""Daily 09:30 bar open and last-bar close per symbol from the 5-min files on disk."""
|
||||||
|
opens, closes = {}, {}
|
||||||
|
files = [f[:-4] for f in os.listdir(BARS) if f.endswith(".pkl")] if os.path.isdir(BARS) else []
|
||||||
|
for s in files:
|
||||||
|
if s not in columns:
|
||||||
|
continue
|
||||||
|
df = pd.read_pickle(os.path.join(BARS, f"{s}.pkl"))
|
||||||
|
day = df.index.tz_localize(None).normalize()
|
||||||
|
first = df.groupby(day).head(1)
|
||||||
|
first = first[(first.index.hour == 9) & (first.index.minute == 30)]
|
||||||
|
opens[s] = pd.Series(first["o"].values.astype(float), index=first.index.tz_localize(None).normalize())
|
||||||
|
closes[s] = df["c"].groupby(day).last().astype(float)
|
||||||
|
return pd.DataFrame(opens), pd.DataFrame(closes), len(opens)
|
||||||
|
|
||||||
|
|
||||||
|
def fidelity(p, sels: dict[str, pd.DataFrame]) -> dict:
|
||||||
|
ao, ac, n = alpaca_opens(set(p.close.columns))
|
||||||
|
if n == 0:
|
||||||
|
return {"symbols": 0}
|
||||||
|
cols = list(ao.columns)
|
||||||
|
dates = p.dates[p.dates >= "2016-01-04"]
|
||||||
|
ao, ac = ao.reindex(index=dates, columns=cols), ac.reindex(index=dates, columns=cols)
|
||||||
|
# overnight with each source's own prior close (adjustment factors cancel within a source)
|
||||||
|
on_y = (p.open / p.close.ffill().shift(1) - 1).reindex(index=dates, columns=cols)
|
||||||
|
on_a = ao / ac.shift(1) - 1
|
||||||
|
mem = p.member.reindex(index=dates, columns=cols).fillna(False)
|
||||||
|
ok = mem & on_y.notna() & on_a.notna()
|
||||||
|
y, a = on_y.where(ok), on_a.where(ok)
|
||||||
|
out = {"symbols": n, "name_days": int(ok.sum().sum()),
|
||||||
|
"corr_all": float(np.corrcoef(y.stack().values, a.stack().values)[0, 1]),
|
||||||
|
"mean_diff_all_bp": float((y - a).stack().mean() * 1e4),
|
||||||
|
"median_abs_diff_all_bp": float((y - a).abs().stack().median() * 1e4)}
|
||||||
|
for name, sel in sels.items():
|
||||||
|
s = sel.reindex(index=dates, columns=cols).fillna(False).astype(bool) & ok
|
||||||
|
d = (y - a).where(s).stack()
|
||||||
|
out[f"{name}_name_days"] = int(s.sum().sum())
|
||||||
|
out[f"{name}_mean_diff_bp"] = float(d.mean() * 1e4) if len(d) else float("nan")
|
||||||
|
out[f"{name}_corr"] = (float(np.corrcoef(y.where(s).stack().values, a.where(s).stack().values)[0, 1])
|
||||||
|
if len(d) > 10 else float("nan"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
res_path = os.path.join(OUT, "results.json")
|
||||||
|
if os.path.exists(res_path):
|
||||||
|
raise SystemExit(f"{res_path} exists; this study does not overwrite results")
|
||||||
|
with open(os.path.join(OUT, "prereg.json"), "w") as fh:
|
||||||
|
json.dump(PREREG, fh, indent=2)
|
||||||
|
|
||||||
|
p, _ = load("us")
|
||||||
|
mem = p.member
|
||||||
|
on, intra = legs(p)
|
||||||
|
scores = {
|
||||||
|
"sleeve_score": _blend(*[f.where(mem).rank(axis=1, pct=True)
|
||||||
|
for f in (overnight_mom(p), rec_126(p))]).where(mem),
|
||||||
|
"overnight_12_1": overnight_mom(p).where(mem),
|
||||||
|
"overnight_1m": overnight_mom_1(p).where(mem),
|
||||||
|
}
|
||||||
|
a, b = SPAN
|
||||||
|
res, daily, sels = {"candidates": {}}, {}, {}
|
||||||
|
for name, sc in scores.items():
|
||||||
|
sel = top_mask(sc.shift(2), TOP_N)
|
||||||
|
sels[name] = sel
|
||||||
|
gross = nightly(sel, on).loc[a:b]
|
||||||
|
net, stress = gross - 2 * COST_BASE, gross - 2 * COST_STRESS
|
||||||
|
clipped = nightly(sel, on.clip(-0.2, 0.2)).loc[a:b] - 2 * COST_BASE
|
||||||
|
big = on.where(sel & (on.abs() > 0.10)).sum(axis=1).loc[a:b] / TOP_N
|
||||||
|
daily[name] = net
|
||||||
|
d = {"gross": describe(gross), "net": describe(net), "stress": describe(stress),
|
||||||
|
"halves": [describe(net.loc[x:y]) for x, y in HALVES],
|
||||||
|
"post_pub": describe(net.loc[POST_PUB[0]:]),
|
||||||
|
"net_clipped_20pct": describe(clipped),
|
||||||
|
"share_of_gross_from_moves_over_10pct": float(big.sum() / gross.sum()),
|
||||||
|
"yearly": {int(k): float(v) for k, v in
|
||||||
|
((1 + net).groupby(net.index.year).prod() - 1).items()}}
|
||||||
|
res["candidates"][name] = d
|
||||||
|
print(f"{name:15s} gross {d['gross']['mean_bp_per_day']:5.1f}bp/d sh {d['gross']['sharpe']:5.2f} | net "
|
||||||
|
f"cagr {d['net']['cagr']*100:5.1f}% vol {d['net']['vol']*100:4.1f}% sh {d['net']['sharpe']:5.2f} "
|
||||||
|
f"mdd {d['net']['mdd']*100:6.1f}% | halves {d['halves'][0]['sharpe']:5.2f} {d['halves'][1]['sharpe']:5.2f}"
|
||||||
|
f" | 2019+ {d['post_pub']['sharpe']:5.2f} | stress {d['stress']['sharpe']:5.2f} | clip "
|
||||||
|
f"{d['net_clipped_20pct']['sharpe']:5.2f} | >10% moves {d['share_of_gross_from_moves_over_10pct']:.2f}",
|
||||||
|
flush=True)
|
||||||
|
print(" yearly net %:", {k: round(v * 100, 1) for k, v in d["yearly"].items()})
|
||||||
|
|
||||||
|
uni = on.where(mem.shift(1).fillna(False).astype(bool)).mean(axis=1).loc[a:b]
|
||||||
|
res["universe_overnight_gross"] = {"full": describe(uni), "halves": [describe(uni.loc[x:y]) for x, y in HALVES],
|
||||||
|
"post_pub": describe(uni.loc[POST_PUB[0]:])}
|
||||||
|
u = res["universe_overnight_gross"]
|
||||||
|
print(f"EW members overnight gross: {u['full']['mean_bp_per_day']:.1f}bp/d sh {u['full']['sharpe']:.2f} "
|
||||||
|
f"halves {u['halves'][0]['sharpe']:.2f} {u['halves'][1]['sharpe']:.2f} 2019+ {u['post_pub']['sharpe']:.2f}")
|
||||||
|
|
||||||
|
split = held_split(scores["sleeve_score"], on, intra)
|
||||||
|
res["sleeve_held_10d_split_gross"] = {}
|
||||||
|
for k, r in split.items():
|
||||||
|
r = r.loc[a:b]
|
||||||
|
res["sleeve_held_10d_split_gross"][k] = {"full": describe(r), "halves": [describe(r.loc[x:y]) for x, y in HALVES],
|
||||||
|
"post_pub": describe(r.loc[POST_PUB[0]:])}
|
||||||
|
s = res["sleeve_held_10d_split_gross"][k]
|
||||||
|
print(f"sleeve held 10d, {k:14s}: {s['full']['mean_bp_per_day']:5.1f}bp/d cagr {s['full']['cagr']*100:5.1f}% "
|
||||||
|
f"sh {s['full']['sharpe']:5.2f} halves {s['halves'][0]['sharpe']:5.2f} {s['halves'][1]['sharpe']:5.2f} "
|
||||||
|
f"2019+ {s['post_pub']['sharpe']:5.2f}")
|
||||||
|
|
||||||
|
fid = fidelity(p, sels)
|
||||||
|
res["fidelity"] = fid
|
||||||
|
print("fidelity:", {k: (round(v, 3) if isinstance(v, float) else v) for k, v in fid.items()})
|
||||||
|
|
||||||
|
n5 = (fid.get("symbols", 0) > 0 and fid["corr_all"] >= 0.9
|
||||||
|
and all(abs(fid[f"{c}_mean_diff_bp"]) <= 3 for c in scores if fid.get(f"{c}_name_days", 0) > 0))
|
||||||
|
checks = {}
|
||||||
|
for name, d in res["candidates"].items():
|
||||||
|
checks[name] = {"N1": d["net"]["sharpe"] >= 1.5, "N2": all(h["sharpe"] >= 1.2 for h in d["halves"]),
|
||||||
|
"N3": d["post_pub"]["sharpe"] >= 1.0, "N4": d["stress"]["sharpe"] >= 1.0}
|
||||||
|
passing = [n for n, c in checks.items() if all(c.values())]
|
||||||
|
decision = "DATA" if not n5 else ("CONTINUE" if passing else "STOP")
|
||||||
|
res["verdict"] = {"checks": checks, "N5": n5, "passing": passing, "decision": decision}
|
||||||
|
print(f"checks {checks}\nN5 {n5} passing {passing} -> {decision}")
|
||||||
|
with open(res_path, "w") as fh:
|
||||||
|
json.dump(res, fh, indent=2)
|
||||||
|
pd.DataFrame(daily).to_pickle(os.path.join(OUT, "daily.pkl"))
|
||||||
|
from research.lab.trials import append_trials
|
||||||
|
append_trials(os.path.join(RESULTS, "trials_us.jsonl"),
|
||||||
|
[{"name": f"overnight_only:{n}", **res["candidates"][n]["net"]} for n in scores],
|
||||||
|
{"study": "overnight_only", "role": "preregistered quick check"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user