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:
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()
|
||||
Reference in New Issue
Block a user