Repository navigation
fix(longbridge): keep GTC/GTD orders alive on transient Expired status - #1125
FaintGhost wants to merge 2 commits into
Conversation
Longbridge marks US-equity GTC/GTD orders as Expired (status 16) between trading sessions; the state reverts to New once the market reopens. The previous mapping treated Expired as Inactive, which the UTA sync loop interpreted as a terminal rejection — the order was dropped from the pending queue and never re-observed, even though it was still alive at the broker (observed live: an NVDA GTC limit filled later and a NET GTC limit stayed open, both misreported as rejected). Disambiguate with timeInForce: Day orders (1) really expire at close and stay Inactive; GTC (2) / GTD (3) Expired maps to Submitted so the sync poller keeps watching until the order fills, cancels, or truly dies.
|
@FaintGhost is attempting to deploy a commit to the luokerenx4's Team Team on Vercel. A member of the Team first needs to authorize it. |
|
Thanks for the detailed live evidence here — the GTC failure mode is convincing, especially the order that later filled after UTA had already made the transient Before we reproduce this on a maintainer-owned branch, could you help us pin down a few Longbridge-specific semantics? We do not currently have a Longbridge test account, and this is a trading state-machine boundary where we would rather use venue evidence than infer behavior.
Our external contribution policy means we will not directly merge a cross-repository trading-surface branch; we use strong external PRs as implementation proposals and reimplement accepted changes on a maintainer-owned branch. Your report and analysis will be credited in |
… to fill Address maintainer review on #1125: 1. GTD is inferred, not observed — only the verified long-lived GTC TIF stays alive on Expired. Day(1) and GTD(3) now return Inactive so genuinely-dead orders (Day close-time expiry, GTD configured-date expiry) do not become permanently-pending UTA orders. Unknown/missing TIF is conservative. 2. Map executedQuantity -> order.filledQuantity so a recovered fill carries its full cost basis. Longbridge orderDetail returns executedQuantity on every non-zero fill (observed: NVDA 3 @ 218.99, ARM 2 @ 286.68); the previous mapping only surfaced avgFillPrice, leaving UTA's fill/cost-basis record incomplete even after the terminal-state fix. Live evidence (sanitized, longbridge-main, via SDK orderDetail): NVDA (later-filled): status=5, timeInForce=2(GTC), qty=3, executedQty=3, executedPrice=218.99 NET (still open): status=1, timeInForce=2, qty=1, executedQty=0 ARM (filled ok): status=5, timeInForce=2, qty=2, executedQty=2, executedPrice=286.68
|
Thanks for the detailed review — all five points are fair, and the code has been revised to address them. To get you venue evidence (not inference), I queried the live account's SDK directly and sanitized the response. All evidence is from On the revised commit ( 1. GTC transition evidence Direct
I did not capture a 2. GTD behavior — you're right, it was inferred. I had no GTD observation. Per your guidance I've narrowed the override to 3. Unknown/missing TIF. In the three observed responses, 4. Fill fidelity — confirmed, and fixed. 5. Safe acceptance path / paper. These observations are from a real-money live account. I have not verified the same after-hours transition on Longbridge paper/demo — I don't have GTC test orders on the paper account. I'd treat the live observation as venue evidence and, if it helps, I can submit a small GTC order on the paper/demo account to reproduce it without real-money exposure (say a far-OTM limit that would never fill). Let me know if you'd like me to run that. The account-side evidence is intentionally kept out of the repo — happy to provide more sanitized field dumps if useful. Thanks again for the tight review; the GTD narrowing and the fill-quantity gap were both good catches. |
… as Expired Longbridge reports US-equity GTC limit orders as Expired (status 16) between sessions and reverts them to New at the open. Mapping every Expired to a terminal status dropped those orders from order-sync, so a later fill or the still-open order was invisible to UTA. Disambiguate on the order's time in force: Expired with GTC stays Submitted and keeps polling; Day and GTD stay terminal, since a GTD parking has not been observed. Evidence and the disambiguation come from upstream TraderAlice#1125 by FaintGhost.
TWS uses Inactive for BOTH a reject and a legitimate hold: an OCA sibling parked behind its partner, a transmit=false bracket parent, an exchange-closed or precautionary hold. A real reject arrives as error() and rejects the pending order promise, so anything that resolves with success: true and Inactive was accepted by the venue. TradingGit.mapOrderStatus mapped Inactive to rejected on the success path, where no error field exists, and UnifiedTradingAccount.sync treated every status other than Submitted/PreSubmitted as terminal. Both wrote a reason-less rejection, and since rejected is terminal the order fell out of order-sync for good. On the live account this produced seven MU target legs reported rejected with no text; all seven were working at IBKR and the eighth came back as IBKR error 201 (15 working orders per side). Held is working: Inactive stays in the pending lane and sync reconciles it. Brokers whose terminal reject is a plain status (Alpaca, CCXT, Longbridge, LeverUp) now report Rejected instead of Inactive, and a rejection with an empty broker message records 'Unknown error' rather than an empty string. "Is this order still working" is then answered the same way everywhere. WORKING_ORDER_STATUSES arrived for order-sync but a second, narrower predicate stayed behind. `PendingCancel` was missing from the set: it means the cancel request has been sent and the venue has not confirmed it, so the order can still fill, yet sync folded it into the terminal `rejected` branch and wrote the same message-less ledger row that `Inactive` was just fixed to avoid. The GitState snapshot still filtered `pendingOrders` on Submitted/PreSubmitted, so a held order stayed in the ledger's pending lane and was chased by sync while being absent from the persisted state and from `pendingOrderCount`. It now reuses the constant. Longbridge has the same shape under a different status. It reports US-equity GTC limit orders as Expired (status 16) between sessions and reverts them to New at the open. Mapping every Expired to a terminal status dropped those orders from order-sync, so a later fill or the still-open order was invisible to UTA. Disambiguate on the order's time in force: Expired with GTC stays Submitted and keeps polling; Day and GTD stay terminal, since a GTD parking has not been observed. Evidence and the disambiguation come from upstream TraderAlice#1125 by FaintGhost.
…on-aware historical bars
Native bracket. placeOrder with takeProfit/stopLoss submits parent +
children (transmit chain, parentId, ocaType=1) and returns
PlaceOrderResult.legs. The bracket always mints its own uta-br-<parentId>
group for the children only; a caller ocaGroup with TP/SL is refused as a
CONFIG error (moving it onto the children made a TP fill cancel unrelated
resting orders). Far targets set overridePercentageConstraints; child TIF is
GTC so protection survives a DAY/GTD fill. Every leg request is observed at
registration so a synchronous client.placeOrder throw cannot become an
unhandled rejection that kills UTA. Live-gateway hardening:
- Refuse TP/SL on a cashQty entry (TWS rejects a notional STP leg, 10244).
- Unwind the chain only when the parent has NOT filled; a filled entry
returns the entry plus acknowledged legs and names the protection gap.
- Cancel completion is gated on a cancel-confirming status (requestOrder
`accepts` predicate), so a fill racing the cancel is not reported
Cancelled over an open position.
- Inactive openOrder/orderStatus is parked for a grace window, not dropped.
- Decimal order fields are recognised across realms, monetary-value orders
send an empty totalQuantity, IBKR UNSET sentinels are stripped, and
OrderHelper.scrub passes Date/Map/Set/RegExp/Error/binary through.
OCA groups. placeOrder accepts ocaType (CLI strings coerced); a group at
type 0, which TWS ignores, becomes CANCEL_WITH_BLOCK. ocaGroup/ocaType/
parentId ride the stage-modify route and show in agent-facing order rows.
Brokers without an OCA primitive (Alpaca, CCXT, Longbridge, LeverUp)
loud-refuse these fields via refuseOcaLinkage before any write; for Alpaca
options that broker-wide refusal is the only owner of the check.
parseOrderLinkId refuses malformed ids ('12abc', '1.9') instead of parseInt
coercion, and the staging boundary refuses parentId <= 0 because IBKR reads
0 as "no parent". TRAIL LIMIT is declared supported.
Modify guards. Verified live: IBKR rejects an OCA/parent revision on a
working order with 10327 when ocaType is sent and silently ignores it
otherwise. IbkrBroker.modifyOrder refuses ocaGroup/ocaType/parentId before
touching the venue and compares every other change against the openOrder
echo, reporting fields the venue ignored. Recipe: place the new leg with the
group, then cancel and re-place the old protective order.
Ledger: broker-held orders stay working. TWS uses Inactive for both rejects
and holds (OCA sibling, transmit=false parent, exchange-closed hold); a real
reject arrives as error(). Inactive and PendingCancel stay in the pending
lane and sync reconciles them, WORKING_ORDER_STATUSES is the single
"still working" predicate (GitState pendingOrders included), other brokers
report plain rejects as Rejected, and an empty reject message records
'Unknown error'. Longbridge Expired with GTC stay Submitted between sessions
(upstream TraderAlice#1125 by FaintGhost); DAY/GTD Expired stays terminal.
Historical bars. ibkr-historical.ts maps BarParams to reqHistoricalData as
pure functions: all eight BarIntervals map to a native bar size, spans clamp
to per-size ceilings, responses are re-bounded locally, error 162 splits into
empty window / pacing (retried NETWORK) / failure, negative volume is unset.
The request is built inside the queued pacing task, with a liveness
re-check, so a start-only window is not under-fetched by queue delay.
BarParams.session ('regular' | 'extended') replaces useRTH and is resolved
per instrument by resolveBarSession (stocks/options regular, futures/CFDs
continuous unless regular is asked, FX/crypto forced continuous), falling
back to a session the broker declares and marking it forced. The result
{ bars, session, forced } flows through the UTA route, the Alice SDK,
BarService meta, marketSnapshot, /api/bars (400 on bad session), the UI
client, and the demo handler (which stamps session only for the UTA source).
Problem
Longbridge reports US-equity GTC/GTD limit orders as
Expired(status 16) between trading sessions — a transient venue state that reverts toNewonce the market reopens. The broker adapter mappedExpired → Inactive, and the UTA order-sync loop treats any non-Submittedstate as terminal (rejected). The order was then dropped from the pending queue and never re-observed.Live evidence (longbridge-main account): three GTC limits submitted together:
So the UTA state machine and the broker disagreed, and the discrepancy was only discoverable by querying the broker directly.
Fix
Disambiguate
Expiredwith the order'stimeInForce:Expired+ Day (1) →Inactive(a Day order genuinely expires unfilled at close)Expired+ GTC (2) / GTD (3) →Submitted(transient between sessions; keep the order in the pending queue so the sync poller keeps observing it until it fills, cancels, or truly terminates)makeOrderStatenow accepts and forwardstimeInForce;mapOpenOrdersupplies it from the broker response.Verification
LongbridgeBroker.spec.ts, including 4 new cases covering the Expired/TIF matrix