diff --git a/overnight_paper.py b/overnight_paper.py index 710a7e0..04abf6a 100644 --- a/overnight_paper.py +++ b/overnight_paper.py @@ -62,6 +62,7 @@ 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) +ACCEPTED_ORDER_STATUSES = {"accepted", "pending_new", "new", "accepted_for_bidding", "partially_filled", "filled"} # --------------------------------------------------------------------------- @@ -209,13 +210,17 @@ def compute_signal(cl: Alpaca, session: date) -> dict: def _state() -> dict: path = os.path.join(OUT, "state.json") - return json.load(open(path)) if os.path.exists(path) else {} + if not os.path.exists(path): + return {} + with open(path) as fh: + return json.load(fh) 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) + with open(tmp, "w") as fh: + json.dump(st, fh, indent=1) os.replace(tmp, os.path.join(OUT, "state.json")) @@ -235,10 +240,18 @@ def _submit(cl: Alpaca, kind: str, payload: dict, dry: bool) -> None: 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) + if resp["http"] == 422 and "client_order_id" in str(resp.get("message", "")).lower(): + existing = cl.get("/orders:by_client_order_id", client_order_id=payload["client_order_id"]) + matches = all(existing.get(k) == payload[k] for k in ("client_order_id", "symbol", "side", "type", "time_in_force")) + matches &= float(existing.get("qty", 0)) == float(payload["qty"]) + if matches and existing.get("id") and existing.get("status") in ACCEPTED_ORDER_STATUSES: + print(f" {tag} -> already accepted ({existing['status']})", flush=True) + return + raise RuntimeError(f"{tag}: client_order_id belongs to a mismatched or {existing.get('status')} order") + if resp["http"] not in (200, 201) or not resp.get("id") or resp.get("status") not in ACCEPTED_ORDER_STATUSES: + reason = str(resp.get("message", resp.get("text", resp.get("status", "missing order acknowledgement"))))[:200] + raise RuntimeError(f"{tag}: HTTP {resp['http']} {reason}") + print(f" {tag} -> ok {resp['status']}", flush=True) # --------------------------------------------------------------------------- @@ -276,7 +289,8 @@ def buy(cl: Alpaca, dry: bool = False, force: bool = False) -> None: 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) + with open(os.path.join(OUT, "signals", f"{today}.json"), "w") as fh: + json.dump(sig, fh, indent=1) acct = cl.get("/account") equity = float(acct["equity"]) px = latest_prices(cl, sig["top"]) diff --git a/tests/test_overnight_paper.py b/tests/test_overnight_paper.py new file mode 100644 index 0000000..95d01d0 --- /dev/null +++ b/tests/test_overnight_paper.py @@ -0,0 +1,109 @@ +import tempfile +import unittest +from datetime import datetime +from unittest import mock + +import pandas as pd + +import overnight_paper as paper + + +class PaperOrders: + def __init__(self, responses, held=None): + self.responses = iter(responses) + self.held = held or [] + self.pending = [] + self.sent = [] + self.existing = None + + def get(self, path, **params): + if path == "/account": + return {"equity": "10000"} + if path == "/positions": + return self.held + if path == "/orders": + return self.pending + if path == "/orders:by_client_order_id" and self.existing: + return self.existing + raise RuntimeError("order not found") + + def order(self, payload): + self.sent.append(payload.copy()) + response = next(self.responses) + if isinstance(response, int): + response = {"http": response, "message": "insufficient buying power"} + else: + response = {"http": 201, "id": str(len(self.sent)), "status": "accepted", **payload, **response} + if response["http"] == 201 and response.get("status") == "accepted": + self.pending.append(response) + return response + + +class OvernightOrderTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + day = datetime(2026, 10, 12, 15, 40, tzinfo=paper.NY) + self.day = day + calendar = pd.DataFrame([{"date": day.date(), "open": day.replace(hour=9, minute=30), + "close": day.replace(hour=16, minute=0)}]).set_index("date") + + class Clock(datetime): + @classmethod + def now(cls, tz=None): + return day.astimezone(tz) + + patches = [mock.patch.object(paper, "OUT", self.tmp.name), + mock.patch.object(paper, "datetime", Clock), + mock.patch.object(paper, "now_utc", return_value=day), + mock.patch.object(paper, "sessions", return_value=calendar), + mock.patch.object(paper, "compute_signal", return_value={ + "top": ["AAA", "BBB"], "prev_close": {"AAA": 100, "BBB": 100}, "bars_through": "2026-10-09"}), + mock.patch.object(paper, "latest_prices", return_value={"AAA": 100, "BBB": 100})] + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + + def test_partial_buy_failure_retries_only_unaccepted_orders(self): + client = PaperOrders([{}, 403, {}]) + with self.assertRaisesRegex(RuntimeError, "HTTP 403"): + paper.buy(client) + self.assertNotIn("bought", paper._state()) + paper.buy(client) + self.assertEqual([o["symbol"] for o in client.sent], ["AAA", "BBB", "BBB"]) + self.assertEqual(paper._state()["bought"], "2026-10-12") + paper.buy(client) + self.assertEqual(len(client.sent), 3) + + def test_partial_sell_failure_does_not_mark_the_session_complete(self): + client = PaperOrders([{}, 403, {}], [{"symbol": "AAA", "qty": "10"}, {"symbol": "BBB", "qty": "20"}]) + with mock.patch.object(paper, "now_utc", return_value=self.day.replace(hour=9, minute=10)): + with self.assertRaisesRegex(RuntimeError, "HTTP 403"): + paper.sell(client) + self.assertNotIn("sold", paper._state()) + paper.sell(client) + self.assertEqual([o["symbol"] for o in client.sent], ["AAA", "BBB", "BBB"]) + self.assertEqual(paper._state()["sold"], "2026-10-12") + + def test_duplicate_is_success_only_for_the_same_accepted_order(self): + payload = {"symbol": "AAA", "qty": "10", "side": "buy", "type": "market", + "time_in_force": "cls", "client_order_id": "onb-2026-10-12-AAA"} + for status, quantity, succeeds in [("accepted", "10", True), ("rejected", "10", False), ("accepted", "11", False)]: + with self.subTest(status=status, quantity=quantity): + client = PaperOrders([{"http": 422, "message": "client_order_id must be unique"}]) + client.existing = {**payload, "id": "existing", "status": status, "qty": quantity} + if succeeds: + paper._submit(client, "moc", payload, False) + else: + with self.assertRaises(RuntimeError): + paper._submit(client, "moc", payload, False) + + def test_http_success_with_rejected_status_is_not_completion(self): + client = PaperOrders([{"status": "rejected"}]) + with self.assertRaisesRegex(RuntimeError, "rejected"): + paper.buy(client) + self.assertNotIn("bought", paper._state()) + + +if __name__ == "__main__": + unittest.main()