diff --git a/runtime/flatten.py b/runtime/flatten.py index 30b484e..7d00720 100644 --- a/runtime/flatten.py +++ b/runtime/flatten.py @@ -61,6 +61,29 @@ def find_overnight_carries(open_symbols, buy_fills, today_et: date) -> list[str] bought_today.add(sym) return sorted(s for s in (open_symbols or ()) if s and s not in bought_today) + +def buy_fills_from_orders(orders) -> list[dict]: + """Extract {symbol, filled_at} for every BUY that actually FILLED shares — + keyed off ``filled_qty>0`` with a ``filled_at``, NOT ``status=='filled'``. + + A marketable buy that PARTIALLY fills then cancels its unfilled remainder + reads ``status=='canceled'`` yet opened a real position; keying off status + would miss it, and the open catch-up flatten would then wrongly close that + fresh position as an overnight carry (the SSPC 84/88 case, 2026-06-24). + Scans bracket legs too. + """ + fills: list[dict] = [] + for o in orders or []: + for x in [o, *(o.get("legs") or [])]: + try: + filled_qty = float(x.get("filled_qty") or 0) + except (TypeError, ValueError): + filled_qty = 0.0 + if str(x.get("side")) == "buy" and x.get("filled_at") and filled_qty > 0: + fills.append({"symbol": x.get("symbol") or o.get("symbol"), + "filled_at": x.get("filled_at")}) + return fills + # Order states in which a sell order still RESERVES position quantity. While an # order is in one of these states, that many shares show as ``held_for_orders`` # and are unavailable to a separate close. (A ``filled``/``canceled`` leg no diff --git a/tests/unit/runtime/test_flatten_carries.py b/tests/unit/runtime/test_flatten_carries.py index 7c10ebe..852620b 100644 --- a/tests/unit/runtime/test_flatten_carries.py +++ b/tests/unit/runtime/test_flatten_carries.py @@ -2,7 +2,7 @@ from datetime import date -from runtime.flatten import find_overnight_carries +from runtime.flatten import buy_fills_from_orders, find_overnight_carries TODAY = date(2026, 6, 22) @@ -46,3 +46,34 @@ def test_no_open_positions_no_carries(): def test_open_with_no_fills_at_all_is_a_carry(): # open but we have zero buy-fill records for it -> treat as carried (fail safe) assert find_overnight_carries({"GHOST"}, [], TODAY) == ["GHOST"] + + +# --- buy_fills_from_orders: which orders count as a real today-entry ---------- +def test_partial_fill_then_canceled_buy_counts_as_a_fill(): + # SSPC-2026-06-24: marketable buy 88 filled 84, remainder canceled -> the + # ORDER status reads 'canceled' but it opened 84 real shares. Must count. + orders = [{"symbol": "SSPC", "side": "buy", "status": "canceled", + "filled_qty": "84", "filled_at": "2026-06-24T13:38:54Z", "legs": None}] + fills = buy_fills_from_orders(orders) + assert [f["symbol"] for f in fills] == ["SSPC"] + + +def test_fully_filled_buy_counts(): + orders = [{"symbol": "NOK", "side": "buy", "status": "filled", + "filled_qty": "1000", "filled_at": "2026-06-24T13:37:18Z"}] + assert [f["symbol"] for f in buy_fills_from_orders(orders)] == ["NOK"] + + +def test_zero_fill_canceled_buy_is_not_a_fill(): + # a backed-out entry that never filled any shares -> NOT a today-entry + orders = [{"symbol": "GRAB", "side": "buy", "status": "canceled", + "filled_qty": "0", "filled_at": None}] + assert buy_fills_from_orders(orders) == [] + + +def test_partial_fill_position_is_not_flattened_as_a_carry(): + # end-to-end: the partial fill makes the position "bought today" -> NOT a carry + fills = buy_fills_from_orders( + [{"symbol": "SSPC", "side": "buy", "status": "canceled", "filled_qty": "84", + "filled_at": "2026-06-24T13:38:54Z"}]) + assert find_overnight_carries({"SSPC"}, fills, date(2026, 6, 24)) == [] diff --git a/trading_execution.py b/trading_execution.py index dee87bc..bc068c6 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -22,7 +22,8 @@ import threading from dataclasses import dataclass from datetime import datetime, timedelta -from runtime.flatten import cancel_protective_and_close, find_overnight_carries +from runtime.flatten import (buy_fills_from_orders, cancel_protective_and_close, + find_overnight_carries) from alpaca_paper.execution import ( AlpacaPaperExecutor, ExecutionRequest, @@ -1226,13 +1227,7 @@ class TradingExecutionService: except Exception as exc: # noqa: BLE001 result["errors"].append(f"orders: {exc}") return result - buy_fills = [] - for o in orders or []: - for x in [o, *(o.get("legs") or [])]: - if (str(x.get("side")) == "buy" and x.get("status") == "filled" - and x.get("filled_at")): - buy_fills.append({"symbol": x.get("symbol") or o.get("symbol"), - "filled_at": x.get("filled_at")}) + buy_fills = buy_fills_from_orders(orders) carries = find_overnight_carries(open_syms, buy_fills, today_et) result["carries"] = carries for symbol in carries: