Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions docs/uta-live-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ cancel. *Guards: editOrder venue quirks, id truncation.*
`placeOrderWithTpSl` override this must REFUSE loudly (never place a naked
entry). On a verified venue: after fill, confirm BOTH protective legs exist
on the exchange — including the trigger/algo namespace — before calling it
working. *Guards: the silent unprotected-position failure, the worst one.*
working. On a native-bracket venue (Alpaca): the push result must carry
`legs` ids, and after the entry fills `order list` must show BOTH legs as
tracked orders. The held SL leg never appears in the venue's open-orders
listing (Alpaca holds it while the TP works) — place-time is the ONLY
moment Alice can learn it exists, so a venue listing diff can NOT recover
a missed leg. *Guards: the silent unprotected-position failure (okx,
ledger lied protected) and its mirror, the naked ledger (alpaca, ledger
blind to real protection) — both fatal to "trust the log".*

**S6 — Standalone stop.** `STP` with a far trigger → accepted → tracked as
`submitted` across passes even though algo orders are invisible to the
Expand Down Expand Up @@ -143,8 +150,44 @@ dead ends.*
- Conditional orders: where do they live (regular vs trigger namespace)?
Document it in the venue's `exchanges/<name>.ts` override file — that
file is the canonical home for every quirk you find.
- Bracket/attached orders: if the venue creates child orders, `placeOrder`
must return their ids via `PlaceOrderResult.legs` so the ledger tracks
them from birth. Verify a leg the venue HIDES from its open-orders
listing (Alpaca's held stop) still shows in `order list` and syncs.
- Amendment identity: does modify keep the order id or mint a new one
(Alpaca replaceOrder does)? After modify, the NEW id must be tracked and
the OLD id must resolve — no ghost pending.
- Error messages from the venue must reach the user (no swallowed response
bodies — the Alpaca opaque-422 lesson).
bodies — the Alpaca opaque-422 lesson; IBKR's >=2000 "informational"
blanket that swallowed 10xxx real errors).
- Hub/leaf: if the venue's search returns directory rows (see S13), wire
them through the nativeKey grammar + expandContract rather than letting
them mis-resolve or vanish.

**S13 — Hub/leaf identity (venues with directory-style search results).**
Search must classify rows: LEAVES carry a tradeable aliceId; DIRECTORIES
(bond issuers, FX families) are marked `expandable: true` and their aliceId
must REFUSE quote/trade with a message pointing at `contract expand`.
Expand each hub kind: FX family → concrete pairs (auto, at search); bond
issuer → individual bonds; underlying + expiry → concrete option contracts;
underlying without expiry → option parameter grid. Every leaf that comes out
must round-trip: aliceId → quote (or a LOUD entitlement error) → place/track/
cancel. *Guards: the symbol-key-assumes-STK mis-resolution, directory rows
dying as unaddressable search noise.*

**S14 — Derivative position signs & units (the four-combo matrix).** Open
all four option combos the venue allows (long/short × call/put; deep-ITM
entries fill blind off intrinsic, shorts fill by selling under fair). For
EACH leg verify on EVERY surface (portfolio tool, UI, simulator):
`side` correct; `avgCost` and `marketPrice` in the SAME unit (venue
averageCost is often multiplier-baked — IBKR reports 103 for an option
bought at 1.03); `unrealizedPnL` sign matches reality for the side; account
equity moves the right direction. Then run `sim price-change` on the
UNDERLYING's symbol: derivative rows must be excluded loudly, never
re-marked with the stock's price (symbol collision produced +23,000%
"moves" and sign-inverted PnL — the community "option direction is
flipped" report). *Guards: unit-mismatched cost basis, symbol-collision
re-marking, sign inversion on recompute surfaces.*

## Scoreboard so far

Expand All @@ -154,3 +197,40 @@ prices, search false negatives, unprotected TP/SL, id truncation, spot
reduceOnly, getOrders crash, and friends. Round 5 (bybit sweep) found zero
new product bugs — the venue-quirk fixes generalized. That's the signal the
catalog converges; keep it that way.

Round 6 (2026-06-12, alpaca market-open): 3 bugs. CLI gateway silently
stripped unknown flags (a typo'd `--quantity` staged a quantity-less LMT
order that committed clean) → strictObject + stage-time per-orderType
required-field gate. Bracket TP/SL legs were untracked from birth — the
ledger was blind to real protection on the exchange, and the held SL leg
is unrecoverable from listings → `PlaceOrderResult.legs` tracked through
the ledger. Plus sync-commit log rows now attribute per-update symbols
(was `unknown`). S2/S3/S4/S5/S6 all green after fixes; OCO leg-cancel
behavior (cancel one → venue kills both) verified and synced faithfully.

Round 7 (2026-06-12, IBKR paper first acceptance run): 5 findings, 2
pre-located by reading the adapter BEFORE connecting (do this for every
new broker). (1) `placeOrder(_tpsl)` silently ignored TP/SL — the okx
naked-entry species, gated with a loud refusal pre-test (native bracket =
parent/child + `legs`, ANG-103 batch). (2) `getOpenOrders` unwired despite
the bridge primitive existing — 5-line wire-up; NOTE reqOpenOrders only
sees THIS clientId's orders, manual TWS-UI orders need reqAllOpenOrders +
permId identity (deferred). (3) By-conId quote → TWS error 321: reqMktData
won't resolve a bare conId even though the wire carries it — enrich via
reqContractDetails once + cache. (4) Account-cache delta semantics: TWS
pushes position DELTAS between accountDownloadEnd markers; the
swap-on-end cache showed a filled sell as still-held for minutes, zero-qty
(closed) updates were dropped entirely, and repeated updates duplicated
rows — upsert-by-conId into live cache + pending. Found by S8's restart
then cross-checking venue truth with an independent-clientId probe.
(5) `decodeContractProto` had an EMPTY `if (cp.secType !== undefined)`
body — a dropped assignment; every portfolio row violated the
IBKR-superset row contract with secType ''.
IBKR venue facts: modify keeps the SAME orderId (assert the inverse of
Alpaca); stops sit `PreSubmitted` (not terminal); paper quotes need
delayed data — full-protobuf REQ_MARKET_DATA_TYPE(3) + REQ_MKT_DATA still
got 10089 (entitlement question parked, price oracle = Alpaca AAPL quote
meanwhile); multi-currency books (HKD+USD) blind-sum at the BROKER layer
(getAccount + aggregateAccountFromPositions) — the live numbers for
ANG-101. S2/S4/S6/S8/S9/S11/S12 green; restart survival incl. TWS
reconnect verified.
2 changes: 1 addition & 1 deletion packages/ibkr/src/decoder/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function decodeContractProto(cp: ContractProto): Contract {
const c = new Contract()
if (cp.conId !== undefined) c.conId = cp.conId
if (cp.symbol !== undefined) c.symbol = cp.symbol
if (cp.secType !== undefined)
if (cp.secType !== undefined) c.secType = coerceSecType(cp.secType)
if (cp.lastTradeDateOrContractMonth !== undefined) c.lastTradeDateOrContractMonth = cp.lastTradeDateOrContractMonth
if (cp.strike !== undefined) c.strike = cp.strike
if (cp.right !== undefined) c.right = cp.right
Expand Down
30 changes: 30 additions & 0 deletions packages/ibkr/tests/protobuf-decode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { CurrentTime } from '../src/protobuf/CurrentTime.js'
import { NextValidId } from '../src/protobuf/NextValidId.js'
import { ErrorMessage } from '../src/protobuf/ErrorMessage.js'
import { ManagedAccounts } from '../src/protobuf/ManagedAccounts.js'
import { PortfolioValue } from '../src/protobuf/PortfolioValue.js'
import { IN } from '../src/message.js'

function encodeProto<T>(codec: { encode(msg: T, writer?: BinaryWriter): BinaryWriter }, msg: T): Buffer {
Expand All @@ -21,6 +22,35 @@ function encodeProto<T>(codec: { encode(msg: T, writer?: BinaryWriter): BinaryWr

describe('Decoder.processProtoBuf', () => {

it('decodes PortfolioValue with full contract identity (secType regression)', () => {
// Regression: decodeContractProto had a dropped assignment — the
// `if (cp.secType !== undefined)` guard had an EMPTY body, so every
// portfolio row reached the UTA layer with secType '' (found live,
// IBKR round: rows violated the IBKR-superset row contract).
const wrapper = new DefaultEWrapper()
const spy = vi.spyOn(wrapper, 'updatePortfolio')
const decoder = new Decoder(wrapper, 222)
applyAllHandlers(decoder)

const buf = encodeProto(PortfolioValue, {
contract: { conId: 265598, symbol: 'AAPL', secType: 'STK', exchange: 'SMART', currency: 'USD', comboLegs: [] },
position: '10',
marketPrice: 291.3,
marketValue: 2913,
averageCost: 254.4,
unrealizedPNL: 368.8,
realizedPNL: 0,
accountName: 'DU1',
})
decoder.processProtoBuf(buf, IN.PORTFOLIO_VALUE)

expect(spy).toHaveBeenCalledOnce()
const contract = spy.mock.calls[0][0]
expect(contract.symbol).toBe('AAPL')
expect(contract.secType).toBe('STK')
expect(contract.conId).toBe(265598)
})

it('decodes CurrentTime and calls wrapper.currentTime', () => {
const wrapper = new DefaultEWrapper()
const spy = vi.spyOn(wrapper, 'currentTime')
Expand Down
64 changes: 64 additions & 0 deletions packages/uta-protocol/src/types/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ export interface Position {

// ==================== Order result ====================

/**
* A protective child order the venue created alongside the entry (bracket
* TP/SL legs). Surfaced so the ledger can track the legs from birth —
* otherwise they exist only on the exchange and every Alice surface
* (order list, sync poller, cancel) is blind to them.
*/
export interface PlaceOrderLeg {
orderId: string
kind: 'takeProfit' | 'stopLoss'
}

/** Result of placeOrder / modifyOrder / closePosition. */
export interface PlaceOrderResult {
success: boolean
Expand All @@ -113,6 +124,51 @@ export interface PlaceOrderResult {
message?: string
execution?: Execution
orderState?: OrderState
/** Bracket TP/SL child orders created by this placement, if any. */
legs?: PlaceOrderLeg[]
}

// ==================== Contract expansion (hub → leaves) ====================

/**
* Filters narrowing a contract expansion. Venue search returns two species:
* LEAVES (tradeable, conId-keyed) and HUBS (directories — a bond issuer, an
* FX currency family, an option chain behind a stock). Expansion turns a hub
* (or a leaf's derivative family) into concrete tradeable leaves.
*/
export interface ExpandContractFilters {
/** Option/future expiry (YYYYMMDD or YYYYMM). For options, switches the
* expansion from the parameter grid to concrete contracts. */
expiry?: string
/** Option right: C or P. */
right?: 'C' | 'P'
strikeMin?: number
strikeMax?: number
/** Which derivative family to expand on an underlying leaf (default OPT). */
secType?: 'OPT' | 'FUT'
/** Max leaves returned (default 60, capped at 200). `total` always reports the full count. */
limit?: number
}

/** One exchange's option-chain parameter set (expirations × strikes). */
export interface OptionGridEntry {
exchange: string
tradingClass: string
multiplier: string
expirations: string[]
strikes: number[]
}

export interface ContractExpansion {
kind: 'contracts' | 'optionGrid'
/** kind=contracts — concrete tradeable leaves, each with its own conId-based aliceId. */
contracts?: Contract[]
/** Full match count before `limit` was applied (never silently truncate). */
total?: number
/** kind=optionGrid — pick an expiry (+ right / strike range) and expand again. */
grid?: OptionGridEntry[]
/** Next-step guidance for the agent. */
hint?: string
}

/** An open/completed order triplet as returned by getOrders(). */
Expand Down Expand Up @@ -335,6 +391,14 @@ export interface IBroker<TMeta = unknown> {
searchContracts(pattern: string): Promise<ContractDescription[]>
getContractDetails(query: Contract): Promise<ContractDetails | null>

/**
* Expand a directory-style nativeKey (bond issuer, FX family) or a leaf's
* derivative family (option chain, futures months) into tradeable leaves.
* Optional — venues without hub semantics leave it undefined; the UTA
* layer loud-refuses.
*/
expandContract?(nativeKey: string, filters?: ExpandContractFilters): Promise<ContractExpansion>

/**
* Refresh the broker's local catalog cache from upstream.
* Optional — only EnumeratingCatalog brokers (Alpaca / CCXT / Mock)
Expand Down
9 changes: 7 additions & 2 deletions packages/uta-protocol/src/types/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import type { Contract, Order, OrderCancel, Execution, OrderState } from '@traderalice/ibkr'
import type Decimal from 'decimal.js'
import type { Position, OpenOrder, TpSlParams } from './broker.js'
import type { Position, OpenOrder, TpSlParams, PlaceOrderLeg } from './broker.js'
import './contract-ext.js'

// ==================== Commit Hash ====================
Expand Down Expand Up @@ -69,6 +69,10 @@ export interface OperationResult {
/** Decimal as string — see filledQty. */
filledPrice?: string
error?: string
/** Bracket TP/SL child orders created alongside this placeOrder (tracked from birth). */
legs?: PlaceOrderLeg[]
/** Symbol for per-row attribution in multi-update sync commits (the op carries none). */
symbol?: string
raw?: unknown
}

Expand Down Expand Up @@ -279,7 +283,8 @@ export interface StageClosePositionParams {
// ==================== Operation Helpers ====================

/** Extract the symbol from any Operation variant. */
export function getOperationSymbol(op: Operation): string {
export function getOperationSymbol(op: Operation | undefined): string {
if (!op) return 'unknown'
switch (op.action) {
case 'placeOrder': return op.contract?.symbol || op.contract?.aliceId || 'unknown'
case 'modifyOrder': return 'unknown' // modifyOrder doesn't carry contract
Expand Down
Loading
Loading