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 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-17 20:47:17 -05:00
1 parent 849bd476f9
commit 9772aca6bc
4 files changed
+61

No files matched your search

+2
View File
@@ -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,
},
)
)
+1
View File
@@ -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):
@@ -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))
+35
View File
@@ -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)))