overnight paper: count an order as sent only when Alpaca accepted it

_submit printed "HTTP 403" and moved on, and buy/sell then marked the
session done, so one rejected MOC/MOO order was never retried. It now
raises unless Alpaca returns an order id with an accepted status; a 422
duplicate client_order_id counts only when the existing order matches the
payload and is live. The session is marked bought/sold only after every
order is accepted, and the daemon retries the rest on its next tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 05:16:12 -07:00
parent 2ff7bc07cf
commit 2e69bc8c7a
2 changed files with 130 additions and 7 deletions

View File

@@ -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"])

View File

@@ -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()