diff --git a/tests/test_tradier_client.py b/tests/test_tradier_client.py index 72a0720..6e91b00 100644 --- a/tests/test_tradier_client.py +++ b/tests/test_tradier_client.py @@ -81,7 +81,9 @@ class TranslateOrderTests(unittest.TestCase): self.assertEqual(o["symbol"], "DAMD") self.assertEqual(o["side"], "buy") self.assertEqual(o["qty"], "8000") - self.assertEqual(o["filled_qty"], "8000") + # fills stay on legs only — parent echoing them double-counts in + # journal reconstruction (flatten_fills adds parent AND legs) + self.assertEqual(o["filled_qty"], "0") # the stop leg is findable the way exit_manager._stop_leg looks stops = [leg for leg in o["legs"] if leg["type"] == "stop" and leg["side"] == "sell" and leg["symbol"] == "DAMD"] @@ -271,3 +273,19 @@ class CancelRacedFillTests(unittest.TestCase): emitted = [c.args[0].__class__.__name__ for c in store.emit.call_args_list] self.assertIn("OrderCancelledEvent", emitted) self.assertNotIn("OrderFilledEvent", emitted) + + +class ByTagLookupFillTests(unittest.TestCase): + def test_lookup_surfaces_entry_leg_fill_on_bracket(self): + client = TradierClient(SETTINGS) + raw = [{"id": 1, "class": "otoco", "status": "open", "tag": "cid-9", + "leg": [{"id": 2, "symbol": "DAMD", "side": "buy", "quantity": 8000, + "type": "limit", "status": "filled", + "exec_quantity": 8000, "avg_fill_price": 1.96}, + {"id": 3, "symbol": "DAMD", "side": "sell", "type": "stop", + "status": "open", "stop_price": 1.8}]}] + with patch.object(client, "_orders_raw", return_value=raw): + found = client.get_order_by_client_order_id("cid-9") + self.assertEqual(found["filled_qty"], "8000") + self.assertEqual(found["filled_avg_price"], "1.96") + self.assertEqual(found["status"], "filled") diff --git a/tradier_broker/client.py b/tradier_broker/client.py index cebc9da..8406f8f 100644 --- a/tradier_broker/client.py +++ b/tradier_broker/client.py @@ -94,16 +94,17 @@ def translate_order(o: dict) -> dict: filled_at = o.get("transaction_date") if status == "filled" else None if legs and not o.get("symbol"): # Tradier OTOCO/OTO PARENT orders carry no top-level symbol/side/qty — - # only the legs do. Inherit from the entry leg (leg[0]) so symbol-scoped - # lookups (stop-leg search, naked-stop guard) can see bracket orders; - # without this the guard reads a protected position as naked and - # force-exits it (2026-07-28 sandbox incident). + # only the legs do. Inherit identity fields from the entry leg (leg[0]) + # so symbol-scoped lookups (stop-leg search, naked-stop guard) can see + # bracket orders; without this the guard reads a protected position as + # naked and force-exits it (2026-07-28 sandbox incident). + # Fill fields are NOT inherited — the legs carry the fills, and a + # parent echoing them double-counts in journal reconstruction + # (storage.journal_backfill.flatten_fills adds parent AND legs). entry_leg = legs[0] o = {**o, "symbol": entry_leg.get("symbol"), "side": o.get("side") or entry_leg.get("side"), "quantity": o.get("quantity") or entry_leg.get("qty"), - "exec_quantity": o.get("exec_quantity") or entry_leg.get("filled_qty"), - "avg_fill_price": o.get("avg_fill_price") or entry_leg.get("filled_avg_price"), "create_date": o.get("create_date") or entry_leg.get("submitted_at"), "transaction_date": o.get("transaction_date") or entry_leg.get("updated_at")} return { @@ -435,7 +436,19 @@ class TradierClient: def get_order_by_client_order_id(self, client_order_id: str) -> dict | None: for o in self._orders_raw(): if (o.get("tag") or "") == client_order_id: - return translate_order(o) + out = translate_order(o) + # bracket container: surface the ENTRY LEG's fill on the parent + # so the cancel-vs-fill reconcile sees executed shares (the + # general translation deliberately leaves fills on legs only) + legs = out.get("legs") or [] + if legs and float(out.get("filled_qty") or 0) == 0: + entry_leg = legs[0] + if float(entry_leg.get("filled_qty") or 0) > 0: + out = {**out, + "filled_qty": entry_leg["filled_qty"], + "filled_avg_price": entry_leg.get("filled_avg_price"), + "status": entry_leg.get("status") or out.get("status")} + return out return None def submit_order(