From 9772aca6bcf9e5505b2593bc023da697a9d4ac73 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Fri, 17 Jul 2026 20:47:17 -0500 Subject: [PATCH] fix(events): add optional side field to OrderFilledEvent OrderFilledEvent had no side field, forcing api/main.py's chart() to backfill it from the sibling order_submitted row. Add an additive-only `side: str | None = None` field (existing payloads without it still parse) and populate it at the sole production emit site (alpaca_paper/execution.py _finalize) from the ExecutionRequest already in scope. The api/main.py backfill workaround is left in place as a harmless fallback for pre-existing events. Co-Authored-By: Claude Fable 5 --- alpaca_paper/execution.py | 2 ++ storage/event_schema.py | 1 + .../alpaca_paper/test_execution_and_sync.py | 23 ++++++++++++ tests/unit/storage/test_event_store.py | 35 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/alpaca_paper/execution.py b/alpaca_paper/execution.py index 5d2c23b..ff7b40b 100644 --- a/alpaca_paper/execution.py +++ b/alpaca_paper/execution.py @@ -216,11 +216,13 @@ class AlpacaPaperExecutor: order_id=request.order_id, fill_price=fill_price or 0.0, fill_quantity=filled_qty, + side=request.side, payload={ "symbol": request.symbol, "order_id": request.order_id, "fill_price": fill_price, "fill_quantity": filled_qty, + "side": request.side, }, ) ) diff --git a/storage/event_schema.py b/storage/event_schema.py index 5b8017a..8538a59 100644 --- a/storage/event_schema.py +++ b/storage/event_schema.py @@ -195,6 +195,7 @@ class OrderFilledEvent(BaseEvent): fill_price: float = Field(description="Fill price") fill_quantity: int = Field(description="Filled quantity") slippage: float | None = Field(default=None, description="Slippage amount") + side: str | None = Field(default=None, description="Buy or sell") class OrderCancelledEvent(BaseEvent): diff --git a/tests/unit/alpaca_paper/test_execution_and_sync.py b/tests/unit/alpaca_paper/test_execution_and_sync.py index f11b404..097b8c9 100644 --- a/tests/unit/alpaca_paper/test_execution_and_sync.py +++ b/tests/unit/alpaca_paper/test_execution_and_sync.py @@ -72,6 +72,29 @@ def test_executor_submits_and_emits(store): assert len(submitted) == 1 +def test_executor_emits_fill_with_side(store): + """OrderFilledEvent must carry the request's side (schema gap fix) so + consumers don't need the order_submitted backfill workaround.""" + class FilledClient(FakeClient): + def submit_order(self, **kw): + self.submitted.append(kw) + return {"id": "broker-123", "status": "filled", + "filled_qty": "10", "filled_avg_price": "14.05"} + + client = FilledClient() + executor = AlpacaPaperExecutor(store, client=client, session_id="t") + request = ExecutionRequest( + symbol="GOOD", side="buy", quantity=10, + entry_price=14.0, stop_loss_price=13.45, + ) + result = executor.execute(request) + assert result.ok + filled = store.query_events(event_type="order_filled") + assert len(filled) == 1 + payload = json.loads(filled[0]["payload_json"]) + assert payload["side"] == "buy" + + def test_executor_rejects_zero_quantity(store): executor = AlpacaPaperExecutor(store, client=FakeClient(), session_id="t") result = executor.execute(ExecutionRequest(symbol="GOOD", side="buy", quantity=0)) diff --git a/tests/unit/storage/test_event_store.py b/tests/unit/storage/test_event_store.py index 174c830..7edb7d0 100644 --- a/tests/unit/storage/test_event_store.py +++ b/tests/unit/storage/test_event_store.py @@ -10,6 +10,7 @@ from storage.event_schema import ( EventMode, OrderApprovalRequestedEvent, OrderApprovedEvent, + OrderFilledEvent, OrderRejectedEvent, OrderSubmittedEvent, SignalReadyEvent, @@ -58,6 +59,40 @@ def test_emit_and_query_round_trip(store): assert payload["signal_data"]["entry_price"] == 5.5 +def test_order_filled_event_side_round_trip(store): + """OrderFilledEvent carries an optional side; must round-trip when set, + and old payloads without it must still parse (additive-only schema change).""" + store.emit( + OrderFilledEvent( + timestamp=T0, + mode=EventMode.PAPER, + message="ABCD filled", + symbol="ABCD", + order_id="o1", + fill_price=5.5, + fill_quantity=10, + side="buy", + ) + ) + rows = store.query_events(event_type="order_filled") + import json + payload = json.loads(rows[0]["payload_json"]) + assert payload["side"] == "buy" + + # additive-only: an event constructed without side still validates and + # defaults to None, exactly like an older persisted record would. + legacy = OrderFilledEvent( + timestamp=T0, + mode=EventMode.PAPER, + message="ABCD filled", + symbol="ABCD", + order_id="o2", + fill_price=5.5, + fill_quantity=10, + ) + assert legacy.side is None + + def test_query_orders_by_timestamp_ascending(store): for i in (3, 1, 2): store.emit(_signal(symbol=f"S{i}", ts=T0 + timedelta(minutes=i)))