Fix: open catch-up flatten wrongly flattened partially-filled fresh positions
Root cause of SSPC getting flattened on a mid-session restart (2026-06-24): flatten_overnight_carries built its today's-buy set from orders with status=="filled" only. But a marketable buy that PARTIALLY fills (SSPC: 84 of 88) cancels its unfilled remainder, so the order reads status=="canceled" even though it opened 84 real shares. The carry-detector saw "no filled buy today" for SSPC, tagged it an overnight carry, and market-closed a position opened ~6 min earlier. (Harmless this time — closed +$20 — but it could cut a runner or realize a loss.) Marketable orders partial-fill far more than resting limits, so flipping to marketable exposed this latent bug. Fix: extract buy_fills_from_orders() (runtime/flatten.py) that counts a buy as a real today-entry by filled_qty>0 + filled_at, NOT status=="filled" (scans legs too). flatten_overnight_carries uses it. Pure + unit-tested: partial-fill counts, full fill counts, zero-fill backout does not, and the partial-fill position is no longer flagged a carry. Note: only manifests on a mid-session restart (a clean session's EOD flatten leaves the book flat, so the open finds no carries). Deploys on next restart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e602b8b30
commit
9ab12bb3d6
3 files changed
+58
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)) == []
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user