fix: bracket fills stay on legs; by-tag lookup surfaces entry-leg fill
General translation must not echo leg fills onto the OTOCO parent — flatten_fills adds parent AND legs, double-counting entries in journal reconstruction. The cancel-vs-fill reconcile still sees executed shares: get_order_by_client_order_id surfaces the entry leg's fill on its result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2de50479a
commit
80422d91cd
2 files changed
+39
-8
No files matched your search
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user