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:
109
tests/test_overnight_paper.py
Normal file
109
tests/test_overnight_paper.py
Normal 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()
|
||||
Reference in New Issue
Block a user