From 7014deb1971ad4115835a31f3b30296fa3b93e4e Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 12 Jun 2026 22:21:17 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(uta,cli):=20round-6=20alpaca=20live=20f?= =?UTF-8?q?indings=20=E2=80=94=20strict=20flags,=20stage-time=20order=20va?= =?UTF-8?q?lidation,=20bracket=20leg=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 6 of the live-testing catalog (docs/uta-live-testing.md), first US-market-open run on the alpaca paper account. Three bugs: 1. CLI gateway silently stripped unknown flags: a typo'd --quantity / --limitPrice staged a quantity-less, price-less LMT order that committed clean. Gateway now parses with z.strictObject (unknown flags 400 loudly, naming the keys). 2. No stage-time required-field validation: the per-orderType rules lived only in tool description prose. UnifiedTradingAccount now refuses at stage time (LMT needs lmtPrice, STP needs auxPrice, totalQuantity XOR cashQty, cashQty MKT-only, TRAIL aux/percent exclusivity; "" treated as absent). 3. Bracket TP/SL legs untracked from birth: Alpaca brackets placed the legs on the venue, but only the parent id entered the ledger — order list, sync poller and cancel were all blind to them, and the held SL leg never appears in the venue's open-orders listing so no later diff could recover it. PlaceOrderResult.legs now carries child ids; TradingGit tracks legs as first-class pending orders (pending scan, known-ids set for the observation pass, sync resolution); compaction surfaces leg ids to the agent. Also: sync-commit log rows attribute per-update symbols (was 'unknown'). Live-verified on alpaca paper: S2 lifecycle, S3 hanger, S4 modify (replaceOrder mints a new id — tracked, no ghost), S5 bracket incl. OCO cancel (one leg cancelled → venue kills both → synced), S6 standalone stop. Account left flat at baseline. Co-Authored-By: Claude Fable 5 --- docs/uta-live-testing.md | 26 ++++++- packages/uta-protocol/src/types/broker.ts | 13 ++++ packages/uta-protocol/src/types/git.ts | 6 +- .../trading/UnifiedTradingAccount.spec.ts | 62 ++++++++++++++++- .../domain/trading/UnifiedTradingAccount.ts | 44 ++++++++++++ .../brokers/alpaca/AlpacaBroker.spec.ts | 57 ++++++++++++++++ .../trading/brokers/alpaca/AlpacaBroker.ts | 11 +++ .../src/domain/trading/git/TradingGit.spec.ts | 67 +++++++++++++++++++ .../uta/src/domain/trading/git/TradingGit.ts | 57 ++++++++++------ src/server/cli.spec.ts | 13 ++++ src/server/cli.ts | 6 +- src/tool/trading-compact.spec.ts | 13 ++++ src/tool/trading-compact.ts | 6 ++ 13 files changed, 354 insertions(+), 27 deletions(-) diff --git a/docs/uta-live-testing.md b/docs/uta-live-testing.md index 88c73a9621..442567fa7c 100644 --- a/docs/uta-live-testing.md +++ b/docs/uta-live-testing.md @@ -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 @@ -143,6 +150,13 @@ dead ends.* - Conditional orders: where do they live (regular vs trigger namespace)? Document it in the venue's `exchanges/.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). @@ -154,3 +168,13 @@ 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. diff --git a/packages/uta-protocol/src/types/broker.ts b/packages/uta-protocol/src/types/broker.ts index f78e1d57e1..b7388b49c6 100644 --- a/packages/uta-protocol/src/types/broker.ts +++ b/packages/uta-protocol/src/types/broker.ts @@ -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 @@ -113,6 +124,8 @@ export interface PlaceOrderResult { message?: string execution?: Execution orderState?: OrderState + /** Bracket TP/SL child orders created by this placement, if any. */ + legs?: PlaceOrderLeg[] } /** An open/completed order triplet as returned by getOrders(). */ diff --git a/packages/uta-protocol/src/types/git.ts b/packages/uta-protocol/src/types/git.ts index eb116c11fe..1b63942fd0 100644 --- a/packages/uta-protocol/src/types/git.ts +++ b/packages/uta-protocol/src/types/git.ts @@ -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 ==================== @@ -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 } diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts index ba9760feb5..aeee53d4a8 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -321,15 +321,71 @@ describe('UTA — stagePlaceOrder', () => { }) it('passes order types through', () => { - const types = ['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL'] - for (const orderType of types) { + // Each type with its required fields (stage-time validation refuses less) + const cases: Array<[string, Record]> = [ + ['MKT', {}], + ['LMT', { lmtPrice: '100' }], + ['STP', { auxPrice: '95' }], + ['STP LMT', { auxPrice: '95', lmtPrice: '94' }], + ['TRAIL', { auxPrice: '5' }], + ] + for (const [orderType, extra] of cases) { const { uta: u } = createUTA() - u.stagePlaceOrder({ aliceId: 'mock-paper|X', action: 'BUY', orderType, totalQuantity: '1' }) + u.stagePlaceOrder({ aliceId: 'mock-paper|X', action: 'BUY', orderType, totalQuantity: '1', ...extra }) const { order } = getStagedPlaceOrder(u) expect(order.orderType).toBe(orderType) } }) + describe('per-orderType required-field gate (stage-time refusal)', () => { + // The bug this guards: a CLI typo (--quantity for --totalQuantity) staged + // a quantity-less, price-less LMT order that committed clean. + const place = (p: Record) => + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', ...p } as never) + + it('refuses LMT without lmtPrice', () => { + expect(() => place({ orderType: 'LMT', totalQuantity: '1' })).toThrow(/requires lmtPrice/) + }) + + it('refuses LMT without any quantity', () => { + expect(() => place({ orderType: 'LMT', lmtPrice: '100' })).toThrow(/requires totalQuantity/) + }) + + it('refuses MKT with neither totalQuantity nor cashQty', () => { + expect(() => place({ orderType: 'MKT' })).toThrow(/totalQuantity .*or cashQty/) + }) + + it('refuses totalQuantity + cashQty together', () => { + expect(() => place({ orderType: 'MKT', totalQuantity: '1', cashQty: '100' })).toThrow(/mutually exclusive/) + }) + + it('refuses cashQty on non-MKT orders', () => { + expect(() => place({ orderType: 'LMT', cashQty: '100', lmtPrice: '100' })).toThrow(/only supported for MKT/) + }) + + it('refuses STP without auxPrice', () => { + expect(() => place({ orderType: 'STP', totalQuantity: '1' })).toThrow(/requires auxPrice/) + }) + + it('refuses STP LMT missing either price', () => { + expect(() => place({ orderType: 'STP LMT', totalQuantity: '1', lmtPrice: '94' })).toThrow(/requires auxPrice/) + expect(() => place({ orderType: 'STP LMT', totalQuantity: '1', auxPrice: '95' })).toThrow(/requires lmtPrice/) + }) + + it('refuses TRAIL with neither/both of auxPrice and trailingPercent', () => { + expect(() => place({ orderType: 'TRAIL', totalQuantity: '1' })).toThrow(/auxPrice .*or trailingPercent/) + expect(() => place({ orderType: 'TRAIL', totalQuantity: '1', auxPrice: '5', trailingPercent: '1' })).toThrow(/mutually exclusive/) + }) + + it('refuses TRAIL LIMIT without lmtPrice', () => { + expect(() => place({ orderType: 'TRAIL LIMIT', totalQuantity: '1', auxPrice: '5' })).toThrow(/requires lmtPrice/) + }) + + it('treats empty string as absent (LLM-emitted "" must not satisfy a requirement)', () => { + expect(() => place({ orderType: 'LMT', totalQuantity: '1', lmtPrice: '' })).toThrow(/requires lmtPrice/) + }) + }) + it('sets totalQuantity as Decimal', () => { uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '42' }) const { order } = getStagedPlaceOrder(uta) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index ca7cbb417a..96775cc105 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -436,8 +436,52 @@ export class UnifiedTradingAccount { } } + /** + * Per-orderType required-field gate, enforced at stage time so a broken + * order can never reach staging/commit. Without this, a caller that loses + * fields on the way in (e.g. a CLI typo like --quantity for --totalQuantity) + * stages a quantity-less LMT order that looks perfectly committable. + */ + private _validatePlaceOrderParams(p: StagePlaceOrderParams): void { + const fail = (msg: string): never => { + throw new Error(`placeOrder (${p.orderType}): ${msg}`) + } + const has = (v: unknown): boolean => v != null && String(v) !== '' + const qty = has(p.totalQuantity) + const cash = has(p.cashQty) + if (qty && cash) fail('totalQuantity and cashQty are mutually exclusive — provide exactly one.') + if (p.orderType === 'MKT') { + if (!qty && !cash) fail('requires totalQuantity (shares) or cashQty (notional).') + } else { + if (cash) fail('cashQty (notional) is only supported for MKT orders — use totalQuantity.') + if (!qty) fail('requires totalQuantity.') + } + switch (p.orderType) { + case 'LMT': + if (!has(p.lmtPrice)) fail('requires lmtPrice.') + break + case 'STP': + if (!has(p.auxPrice)) fail('requires auxPrice (stop trigger price).') + break + case 'STP LMT': + if (!has(p.auxPrice)) fail('requires auxPrice (stop trigger price).') + if (!has(p.lmtPrice)) fail('requires lmtPrice.') + break + case 'TRAIL': + case 'TRAIL LIMIT': { + const aux = has(p.auxPrice) + const pct = has(p.trailingPercent) + if (aux && pct) fail('auxPrice and trailingPercent are mutually exclusive — provide exactly one.') + if (!aux && !pct) fail('requires auxPrice (trailing offset) or trailingPercent.') + if (p.orderType === 'TRAIL LIMIT' && !has(p.lmtPrice)) fail('requires lmtPrice.') + break + } + } + } + stagePlaceOrder(params: StagePlaceOrderParams): AddResult { this._assertWritable() + this._validatePlaceOrderParams(params) // Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.) const contract = this.contractFromAliceId(params.aliceId) if (params.symbol) contract.symbol = params.symbol diff --git a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts index 9749e37e17..db9fcc4b31 100644 --- a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts @@ -120,6 +120,63 @@ describe('AlpacaBroker — placeOrder()', () => { expect(result.orderId).toBe('ord-1') }) + it('surfaces bracket leg ids with kinds (ledger tracks legs from birth)', async () => { + const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) + ;(acc as any).client = { + createOrder: vi.fn().mockResolvedValue({ + id: 'parent-1', status: 'new', order_class: 'bracket', + legs: [ + { id: 'tp-1', type: 'limit', limit_price: '297', stop_price: null, status: 'held' }, + { id: 'sl-1', type: 'stop', limit_price: null, stop_price: '285.5', status: 'held' }, + ], + }), + } + const contract = new Contract() + contract.aliceId = 'alpaca-paper|AAPL' + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'NASDAQ' + contract.currency = 'USD' + + const order = new Order() + order.action = 'BUY' + order.orderType = 'LMT' + order.totalQuantity = new Decimal(1) + order.lmtPrice = new Decimal('291.57') + + const result = await acc.placeOrder(contract, order, { + takeProfit: { price: '297' }, + stopLoss: { price: '285.5' }, + }) + expect(result.success).toBe(true) + expect(result.legs).toEqual([ + { orderId: 'tp-1', kind: 'takeProfit' }, + { orderId: 'sl-1', kind: 'stopLoss' }, + ]) + }) + + it('omits legs for simple (non-bracket) placements', async () => { + const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) + ;(acc as any).client = { + createOrder: vi.fn().mockResolvedValue({ id: 'ord-2', status: 'new' }), + } + const contract = new Contract() + contract.aliceId = 'alpaca-paper|AAPL' + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'NASDAQ' + contract.currency = 'USD' + + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal(1) + + const result = await acc.placeOrder(contract, order) + expect(result.success).toBe(true) + expect('legs' in result).toBe(false) + }) + it('returns error when contract resolution fails', async () => { const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) ;(acc as any).client = { createOrder: vi.fn() } diff --git a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index ce330d170a..2a02ee1da1 100644 --- a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -309,10 +309,21 @@ export class AlpacaBroker implements IBroker { } const result = await this.client.createOrder(alpacaOrder) as AlpacaOrderRaw + // Bracket legs: surface child order ids so the ledger tracks them from + // birth. The held stop leg never appears in the open-orders listing + // (Alpaca keeps it 'held' while the TP works), so place-time is the + // ONLY moment Alice can learn it exists. + const legs = (result.legs ?? []) + .filter((l) => l.id) + .map((l) => ({ + orderId: l.id, + kind: (l.stop_price ? 'stopLoss' : 'takeProfit') as 'stopLoss' | 'takeProfit', + })) return { success: true, orderId: result.id, orderState: makeOrderState(result.status), + ...(legs.length > 0 ? { legs } : {}), } } catch (err) { return { success: false, error: alpacaErrorMessage(err) } diff --git a/services/uta/src/domain/trading/git/TradingGit.spec.ts b/services/uta/src/domain/trading/git/TradingGit.spec.ts index 69efb6dd59..d59cec5f7a 100644 --- a/services/uta/src/domain/trading/git/TradingGit.spec.ts +++ b/services/uta/src/domain/trading/git/TradingGit.spec.ts @@ -722,6 +722,49 @@ describe('TradingGit', () => { expect(gitP.getPendingOrderIds()).toHaveLength(0) }) + it('tracks bracket TP/SL legs from birth (Alpaca naked-ledger bug)', async () => { + // The bug: bracket legs existed only on the exchange — order list, + // sync poller, and cancel were all blind to them; the held SL leg + // never even appears in the venue's open-orders listing. + const legConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: 'parent-1', + legs: [ + { orderId: 'leg-tp', kind: 'takeProfit' }, + { orderId: 'leg-sl', kind: 'stopLoss' }, + ], + }), + }) + const gitP = new TradingGit(legConfig) + + gitP.add(buyOp('AAPL')) + gitP.commit('bracket buy') + await gitP.push() + + const pending = gitP.getPendingOrderIds() + expect(pending.map((p) => p.orderId).sort()).toEqual(['leg-sl', 'leg-tp', 'parent-1']) + // Legs inherit the parent operation's contract (symbol-scoped lookups + // + restart survival need it). + for (const p of pending) expect(p.symbol).toBe('AAPL') + + // Observation pass must never re-record our own legs as external. + const known = gitP.getKnownOrderIds() + expect(known.has('leg-tp')).toBe(true) + expect(known.has('leg-sl')).toBe(true) + + // A later sync resolving a leg removes it from pending, keeps the rest. + await gitP.sync( + [{ + orderId: 'leg-tp', symbol: 'AAPL', + previousStatus: 'submitted', currentStatus: 'filled', + filledPrice: '297', filledQty: '1', + }], + makeGitState(), + ) + expect(gitP.getPendingOrderIds().map((p) => p.orderId).sort()).toEqual(['leg-sl', 'parent-1']) + }) + it('excludes orders that were filled at push time (no sync needed)', async () => { const orderState = new OrderState() orderState.status = 'Filled' @@ -743,6 +786,30 @@ describe('TradingGit', () => { }) }) + describe('log — sync commit attribution', () => { + it('renders one row per sync update, attributed by the update symbol', async () => { + const gitS = new TradingGit(makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ success: true, orderId: 'o-1' }), + })) + gitS.add(buyOp('AAPL')) + gitS.commit('buy') + await gitS.push() + + await gitS.sync( + [ + { orderId: 'o-1', symbol: 'AAPL', previousStatus: 'submitted', currentStatus: 'filled', filledPrice: '150', filledQty: '10' }, + { orderId: 'o-2', symbol: 'TSLA', previousStatus: 'submitted', currentStatus: 'cancelled' }, + ], + makeGitState(), + ) + + const [head] = gitS.log({ limit: 1 }) + expect(head.operations).toHaveLength(2) + expect(head.operations.map((o) => o.symbol)).toEqual(['AAPL', 'TSLA']) + expect(head.operations[0].change).toContain('@150') + }) + }) + // ==================== simulatePriceChange ==================== describe('simulatePriceChange', () => { diff --git a/services/uta/src/domain/trading/git/TradingGit.ts b/services/uta/src/domain/trading/git/TradingGit.ts index 983c5976ef..870bba0645 100644 --- a/services/uta/src/domain/trading/git/TradingGit.ts +++ b/services/uta/src/domain/trading/git/TradingGit.ts @@ -311,6 +311,7 @@ export class TradingGit implements ITradingGit { for (const commit of this.commits) { for (const result of commit.results) { if (result.orderId) known.add(result.orderId) + for (const leg of result.legs ?? []) known.add(leg.orderId) } } return known @@ -347,10 +348,14 @@ export class TradingGit implements ITradingGit { ): OperationSummary[] { const summaries: OperationSummary[] = [] - for (let i = 0; i < commit.operations.length; i++) { - const op = commit.operations[i] + // Sync commits store ONE syncOrders op with N per-order results — iterate + // the longer of the two so every update gets its own row, attributed by + // the result's own symbol (the op carries none). + const count = Math.max(commit.operations.length, commit.results.length) + for (let i = 0; i < count; i++) { + const op = commit.operations[i] ?? commit.operations[0] const result = commit.results[i] - const symbol = getOperationSymbol(op) + const symbol = result?.symbol || getOperationSymbol(op) if (filterSymbol && symbol !== filterSymbol) continue @@ -572,6 +577,7 @@ export class TradingGit implements ITradingGit { action: 'syncOrders' as const, success: true, orderId: u.orderId, + symbol: u.symbol, status: u.currentStatus, filledQty: u.filledQty, filledPrice: u.filledPrice, @@ -590,7 +596,10 @@ export class TradingGit implements ITradingGit { } getPendingOrderIds(): Array<{ orderId: string; symbol: string; localSymbol?: string; aliceId?: string }> { - // Scan newest→oldest to find latest known status per orderId + // Scan newest→oldest to find latest known status per orderId. + // Bracket TP/SL legs ride in result.legs — born 'submitted'; any later + // sync row for a leg lives in a newer commit and wins (first-seen-wins + // over a newest-first scan). const orderStatus = new Map() for (let i = this.commits.length - 1; i >= 0; i--) { @@ -598,6 +607,9 @@ export class TradingGit implements ITradingGit { if (result.orderId && !orderStatus.has(result.orderId)) { orderStatus.set(result.orderId, result.status) } + for (const leg of result.legs ?? []) { + if (!orderStatus.has(leg.orderId)) orderStatus.set(leg.orderId, 'submitted') + } } } @@ -608,27 +620,30 @@ export class TradingGit implements ITradingGit { for (const commit of this.commits) { for (let j = 0; j < commit.results.length; j++) { const result = commit.results[j] - if ( - result.orderId && - !seen.has(result.orderId) && - orderStatus.get(result.orderId) === 'submitted' - ) { - const op = commit.operations[j] - const symbol = getOperationSymbol(op) - // Broker-native symbol for symbol-scoped order lookups (CCXT). - // Persisted with the operation, so it survives process restarts - // where the broker's in-memory orderId→symbol cache is empty. - const hasContract = - op?.action === 'placeOrder' || op?.action === 'closePosition' || op?.action === 'observeExternalOrder' - const localSymbol = hasContract ? op.contract?.localSymbol || undefined : undefined - const aliceId = hasContract ? op.contract?.aliceId || undefined : undefined + const op = commit.operations[j] + const symbol = getOperationSymbol(op) + // Broker-native symbol for symbol-scoped order lookups (CCXT). + // Persisted with the operation, so it survives process restarts + // where the broker's in-memory orderId→symbol cache is empty. + const hasContract = + op?.action === 'placeOrder' || op?.action === 'closePosition' || op?.action === 'observeExternalOrder' + const localSymbol = hasContract ? op.contract?.localSymbol || undefined : undefined + const aliceId = hasContract ? op.contract?.aliceId || undefined : undefined + + // Parent order + its bracket legs share the operation's contract. + const candidates = [ + ...(result.orderId ? [result.orderId] : []), + ...(result.legs ?? []).map((l) => l.orderId), + ] + for (const orderId of candidates) { + if (seen.has(orderId) || orderStatus.get(orderId) !== 'submitted') continue pending.push({ - orderId: result.orderId, + orderId, symbol, ...(localSymbol && { localSymbol }), ...(aliceId && { aliceId }), }) - seen.add(result.orderId) + seen.add(orderId) } } } @@ -823,6 +838,7 @@ export class TradingGit implements ITradingGit { const orderId = rawObj.orderId as string | undefined const orderState = rawObj.orderState as OperationResult['orderState'] + const legs = rawObj.legs as OperationResult['legs'] return { action: op.action, @@ -830,6 +846,7 @@ export class TradingGit implements ITradingGit { orderId, status: this.mapOrderStatus(orderState), orderState, + ...(Array.isArray(legs) && legs.length > 0 ? { legs } : {}), raw, } } diff --git a/src/server/cli.spec.ts b/src/server/cli.spec.ts index 8b1d9c37c1..392f23e952 100644 --- a/src/server/cli.spec.ts +++ b/src/server/cli.spec.ts @@ -81,6 +81,19 @@ describe('CLI gateway — data export', () => { expect(res.status).toBe(400) }) + it('invoke 400s LOUDLY on an unknown flag, naming the key', async () => { + // Guards the silent-drop bug: a typo'd flag (--quantity for + // --totalQuantity) was stripped by non-strict parsing, staging a + // quantity-less order that validated clean. + const res = await post('/cli/ws1/data/invoke', { + tool: 'calculate', + args: { expression: '1 + 1', expresion: 'typo' }, + }) + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string; details?: string } + expect(body.details).toMatch(/expresion/) + }) + it('invoke 404s on unknown workspace', async () => { const res = await post('/cli/nope/data/invoke', { tool: 'calculate', args: { expression: '1' } }) expect(res.status).toBe(404) diff --git a/src/server/cli.ts b/src/server/cli.ts index 39e138c80c..4e36b70d4b 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -161,8 +161,10 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void { body.args && typeof body.args === 'object' ? (body.args as Record) : {} // Same validate+coerce path as the MCP boundary (string -> number etc.), - // so the client may send every flag as a raw string. - const schema = z.object(extractMcpShape(tool)) + // so the client may send every flag as a raw string. strictObject: an + // unknown flag must error, not silently vanish — a typo'd --quantity + // once staged a quantity-less order that validated clean. + const schema = z.strictObject(extractMcpShape(tool)) let validated: Record try { validated = await schema.parseAsync(rawArgs) diff --git a/src/tool/trading-compact.spec.ts b/src/tool/trading-compact.spec.ts index ecb23c2fda..852b29d8cc 100644 --- a/src/tool/trading-compact.spec.ts +++ b/src/tool/trading-compact.spec.ts @@ -91,6 +91,19 @@ describe('compactOperation / compactStatus / compactResult', () => { expect(r).toEqual({ action: 'placeOrder', success: false, status: 'rejected', error: 'price band', rejectReason: 'okx 51138' }) }) + it('compactResult surfaces bracket leg ids (agent confirmation that protective legs exist)', () => { + const r = compactResult({ + action: 'placeOrder', success: true, status: 'submitted', orderId: 'parent-1', + legs: [{ orderId: 'tp-1', kind: 'takeProfit' }, { orderId: 'sl-1', kind: 'stopLoss' }], + raw: { huge: 'payload' }, + }) + expect(r['legs']).toEqual([ + { orderId: 'tp-1', kind: 'takeProfit' }, + { orderId: 'sl-1', kind: 'stopLoss' }, + ]) + expect('raw' in r).toBe(false) + }) + it('compactStatus compacts staged ops and renames pending→awaitingApproval', () => { const idle = compactStatus({ staged: [{ action: 'cancelOrder', orderId: 'o1' }], diff --git a/src/tool/trading-compact.ts b/src/tool/trading-compact.ts index 6c498a8f85..0b19e53397 100644 --- a/src/tool/trading-compact.ts +++ b/src/tool/trading-compact.ts @@ -155,6 +155,12 @@ export function compactResult(r: unknown): AnyRec { pick(out, 'filledQty', val(k['filledQty'])) pick(out, 'filledPrice', price(k['filledPrice'])) pick(out, 'error', val(k['error'])) + // Bracket TP/SL leg ids — the agent's only confirmation the protective + // legs exist (and the handle for cancelling them). + const legs = k['legs'] as Array<{ orderId?: unknown; kind?: unknown }> | undefined + if (Array.isArray(legs) && legs.length > 0) { + out['legs'] = legs.map((l) => ({ orderId: l.orderId, kind: l.kind })) + } const orderState = k['orderState'] as AnyRec | undefined const rejectReason = orderState ? val(orderState['rejectReason']) : undefined if (rejectReason) out['rejectReason'] = rejectReason From 693f5f163e06d03b5e3d348eab14ab1c252ead1a Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 12 Jun 2026 23:29:28 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(ibkr):=20round-7=20IBKR=20paper=20accep?= =?UTF-8?q?tance=20=E2=80=94=20TPSL=20gate,=20open-orders=20wiring,=20conI?= =?UTF-8?q?d=20quotes,=20account-cache=20deltas,=20secType=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First live-testing-catalog acceptance run against IBKR TWS paper (docs/uta-live-testing.md round 7). Five findings: 1. placeOrder silently ignored its tpsl param (underscore-prefixed) — the ledger would record protection TWS never received. Now refuses loudly until the native parent/child bracket lands (ANG-103). 2. getOpenOrders unwired despite bridge.requestOpenOrders existing — wired (this-clientId scope; manual TWS-UI order observation needs reqAllOpenOrders + permId identity, deferred to Linear). 3. By-conId getQuote hit TWS error 321 — reqMktData refuses to resolve a bare conId even though the wire field is sent. Enrich once via reqContractDetails, cached per conId. Also: reqMarketDataType(3) declared at init + delayed tick types (66-73) mapped in the snapshot collector (paper accounts have no live entitlement). 4. Account-cache delta semantics: TWS pushes position deltas BETWEEN accountDownloadEnd markers; the swap-on-end cache left a filled sell showing as still-held for minutes, dropped zero-quantity (closed) updates entirely, and duplicated rows on repeated updates. Now upsert-by-conId into both the live cache and the pending rebuild buffer; account values apply immediately too. 5. decodeContractProto had an empty `if (cp.secType !== undefined)` body — dropped assignment; every protobuf portfolio row reached the UTA layer with secType ''. Also: dayTradesRemaining omitted when TWS doesn't report the tag (was fabricating 0; -1 = unlimited passes through). Live-verified on TWS paper: S2 lifecycle (fill @292.50 via execution data), S4 modify (same-id semantics — inverse of Alpaca), S6 standalone stop (PreSubmitted tracked, not mis-terminaled), S8 restart survival incl. TWS reconnect, S9 partial close, S12 staging undo. Account left flat at baseline. ANG-101 mechanism confirmed live (broker-layer HKD+USD blind sum) — diagnosis recorded, FX fix out of scope. Co-Authored-By: Claude Fable 5 --- docs/uta-live-testing.md | 27 ++++++ packages/ibkr/src/decoder/account.ts | 2 +- packages/ibkr/tests/protobuf-decode.spec.ts | 30 +++++++ .../trading/brokers/ibkr/IbkrBroker.spec.ts | 55 ++++++++++++ .../domain/trading/brokers/ibkr/IbkrBroker.ts | 51 +++++++++++- .../brokers/ibkr/request-bridge.spec.ts | 83 +++++++++++++++++++ .../trading/brokers/ibkr/request-bridge.ts | 55 +++++++++--- 7 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts create mode 100644 services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts diff --git a/docs/uta-live-testing.md b/docs/uta-live-testing.md index 442567fa7c..284186408a 100644 --- a/docs/uta-live-testing.md +++ b/docs/uta-live-testing.md @@ -178,3 +178,30 @@ 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. diff --git a/packages/ibkr/src/decoder/account.ts b/packages/ibkr/src/decoder/account.ts index 9e0ee18ed5..3dc05175b5 100644 --- a/packages/ibkr/src/decoder/account.ts +++ b/packages/ibkr/src/decoder/account.ts @@ -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 diff --git a/packages/ibkr/tests/protobuf-decode.spec.ts b/packages/ibkr/tests/protobuf-decode.spec.ts index aa3c32f4b7..05a8ea5032 100644 --- a/packages/ibkr/tests/protobuf-decode.spec.ts +++ b/packages/ibkr/tests/protobuf-decode.spec.ts @@ -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(codec: { encode(msg: T, writer?: BinaryWriter): BinaryWriter }, msg: T): Buffer { @@ -21,6 +22,35 @@ function encodeProto(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') diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts new file mode 100644 index 0000000000..770d76b6ef --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' +import { Contract, Order } from '@traderalice/ibkr' +import { IbkrBroker } from './IbkrBroker.js' + +/** + * The gate must fire BEFORE any bridge/client access, so it is testable on + * a bare prototype instance — no TWS connection, no bridge construction. + */ +function bareBroker(): IbkrBroker { + return Object.create(IbkrBroker.prototype) as IbkrBroker +} + +function stkOrder(): { contract: Contract; order: Order } { + const contract = new Contract() + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'SMART' + contract.currency = 'USD' + const order = new Order() + order.action = 'BUY' + order.orderType = 'LMT' + order.totalQuantity = new Decimal(1) + order.lmtPrice = new Decimal(100) + return { contract, order } +} + +describe('IbkrBroker — attached TP/SL refusal gate', () => { + // Guards the silent naked-entry failure: the tpsl param used to be + // `_tpsl` (ignored) — the ledger recorded protection TWS never received. + it('refuses placeOrder with takeProfit', async () => { + const { contract, order } = stkOrder() + const result = await bareBroker().placeOrder(contract, order, { takeProfit: { price: '120' } }) + expect(result.success).toBe(false) + expect(result.error).toMatch(/TP\/SL.*not implemented|refusing/i) + }) + + it('refuses placeOrder with stopLoss', async () => { + const { contract, order } = stkOrder() + const result = await bareBroker().placeOrder(contract, order, { stopLoss: { price: '90' } }) + expect(result.success).toBe(false) + expect(result.error).toMatch(/refusing/i) + }) + + it('an empty tpsl object does not trip the gate', async () => { + const { contract, order } = stkOrder() + // No bridge on the bare instance — passing the gate means it throws on + // bridge access, NOT a refusal result. + await expect(async () => { + const r = await bareBroker().placeOrder(contract, order, {}) + if (r.success === false && /refusing/i.test(r.error ?? '')) throw new Error('gate tripped') + return r + }).not.toThrow(/gate tripped/) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 3fcc16cc9d..ad5121b8a6 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -106,6 +106,11 @@ export class IbkrBroker implements IBroker { throw BrokerError.from(err, 'NETWORK') } + // Delayed-data fallback: without this, a snapshot on an unsubscribed + // symbol (every paper account) gets neither ticks nor an error — the + // request just times out. Type 3 = live when entitled, delayed otherwise. + this.client.reqMarketDataType(3) + // Resolve account ID this.accountId = this.config.accountId ?? this.bridge.getAccountId() if (!this.accountId) { @@ -150,7 +155,17 @@ export class IbkrBroker implements IBroker { // ==================== Trading operations ==================== - async placeOrder(contract: Contract, order: Order, _tpsl?: TpSlParams): Promise { + async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise { + // Attached TP/SL: not implemented yet (native path = parent + child + // orders with parentId + transmit chain — see ANG-103 batch). Refuse + // loudly rather than silently placing an unprotected entry; the ledger + // would otherwise record protection the venue never received. + if (tpsl?.takeProfit || tpsl?.stopLoss) { + return { + success: false, + error: 'IBKR attached TP/SL (bracket) is not implemented yet — refusing to place a naked entry. Place the entry first, then a standalone STP/LMT protective order.', + } + } // TWS requires exchange and currency on the contract. Upstream layers // (staging, AI tools) typically only populate symbol + secType. // Default to SMART routing. Currency defaults to USD — non-USD markets @@ -290,7 +305,9 @@ export class IbkrBroker implements IBroker { buyingPower: new Decimal(download.values.get('BuyingPower') ?? '0').toString(), initMarginReq: new Decimal(download.values.get('InitMarginReq') ?? '0').toString(), maintMarginReq: new Decimal(download.values.get('MaintMarginReq') ?? '0').toString(), - dayTradesRemaining: parseInt(download.values.get('DayTradesRemaining') ?? '0', 10), + ...(download.values.has('DayTradesRemaining') + ? { dayTradesRemaining: parseInt(download.values.get('DayTradesRemaining')!, 10) } + : {}), } } @@ -322,6 +339,17 @@ export class IbkrBroker implements IBroker { .map(o => this.enrichWithFillData(o)) } + /** + * All open orders placed through this client — external-order observation + * + listing-driven sync surface. NOTE: reqOpenOrders only returns THIS + * clientId's orders; manual TWS-UI orders need reqAllOpenOrders + permId + * identity (deferred — tracked in Linear). + */ + async getOpenOrders(): Promise { + const allOrders = await this.bridge.requestOpenOrders() + return allOrders.map(o => this.enrichWithFillData(o)) + } + async getOrder(orderId: string): Promise { // Try open orders first const results = await this.getOrders([orderId]) @@ -356,10 +384,29 @@ export class IbkrBroker implements IBroker { * Each call briefly occupies one TWS market data line (limit ~100), * auto-released after tickSnapshotEnd. */ + /** conId → resolved full contract, so by-conId quotes pay reqContractDetails once. */ + private readonly conIdContracts = new Map() + async getQuote(contract: Contract): Promise { if (!contract.exchange) contract.exchange = 'SMART' if (!contract.currency) contract.currency = 'USD' + // TWS rejects reqMktData on a bare conId (error 321: symbol/localSymbol/ + // secId required) even though the wire carries conId — resolution by + // conId is only honoured via reqContractDetails. Enrich once and cache. + if (contract.conId && !contract.symbol && !contract.localSymbol) { + let full = this.conIdContracts.get(contract.conId) + if (!full) { + const details = await this.getContractDetails(contract) + if (!details?.contract) { + throw new BrokerError('EXCHANGE', `conId ${contract.conId} did not resolve to a contract`) + } + full = details.contract + this.conIdContracts.set(contract.conId, full) + } + contract = full + } + const reqId = this.bridge.allocReqId() const promise = this.bridge.requestSnapshot(reqId) this.client.reqMktData(reqId, contract, '', true, false, []) diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts new file mode 100644 index 0000000000..aa6d5b2c6a --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' +import { Contract } from '@traderalice/ibkr' +import { RequestBridge } from './request-bridge.js' + +function stk(conId: number, symbol: string): Contract { + const c = new Contract() + c.conId = conId + c.symbol = symbol + c.secType = 'STK' + c.currency = 'USD' + return c +} + +function pushUpdate(b: RequestBridge, contract: Contract, qty: number, avgCost = '100'): void { + b.updatePortfolio(contract, new Decimal(qty), '101', String(qty * 101), avgCost, '1', '0', 'DU1') +} + +/** + * TWS account-subscription semantics: full download bursts end with + * accountDownloadEnd; between bursts TWS pushes DELTAS with no end marker + * (a fill updates one position immediately; the next full download can be + * ~3 minutes away). The cache used to apply deltas only at the next swap — + * the ledger said filled while the portfolio surface showed the old + * quantity for minutes (found live, IBKR round, S8). + */ +describe('RequestBridge — account cache delta semantics', () => { + function readyBridge(): RequestBridge { + const b = new RequestBridge() + ;(b as unknown as { accountCachePending_: unknown }).accountCachePending_ = { positions: [], values: new Map() } + pushUpdate(b, stk(1, 'AAPL'), 10) + pushUpdate(b, stk(2, 'TSLA'), 5) + b.updateAccountValue('TotalCashValue', '1000', 'USD', 'DU1') + b.accountDownloadEnd('DU1') + return b + } + + it('applies a delta update to the live cache immediately (no downloadEnd needed)', () => { + const b = readyBridge() + pushUpdate(b, stk(1, 'AAPL'), 9) + + const cache = b.getAccountCache()! + const aapl = cache.positions.find((p) => p.contract.conId === 1)! + expect(aapl.quantity.toNumber()).toBe(9) + expect(cache.positions).toHaveLength(2) + }) + + it('removes a fully-closed position (zero quantity) immediately', () => { + const b = readyBridge() + pushUpdate(b, stk(2, 'TSLA'), 0) + + const cache = b.getAccountCache()! + expect(cache.positions.map((p) => p.contract.conId)).toEqual([1]) + }) + + it('applies account-value deltas to the live cache immediately', () => { + const b = readyBridge() + b.updateAccountValue('TotalCashValue', '900', 'USD', 'DU1') + expect(b.getAccountCache()!.values.get('TotalCashValue')).toBe('900') + }) + + it('repeated updates within one batch window do not duplicate rows', () => { + const b = readyBridge() + // price-tick churn: same position updated 3x before the next downloadEnd + pushUpdate(b, stk(1, 'AAPL'), 9) + pushUpdate(b, stk(1, 'AAPL'), 9) + pushUpdate(b, stk(2, 'TSLA'), 5) + b.accountDownloadEnd('DU1') + + const cache = b.getAccountCache()! + expect(cache.positions).toHaveLength(2) + expect(cache.positions.find((p) => p.contract.conId === 1)!.quantity.toNumber()).toBe(9) + }) + + it('full-download swap does not resurrect a position closed mid-window', () => { + const b = readyBridge() + pushUpdate(b, stk(2, 'TSLA'), 0) // closed via delta + pushUpdate(b, stk(1, 'AAPL'), 10) // next full burst: only AAPL remains + b.accountDownloadEnd('DU1') + + expect(b.getAccountCache()!.positions.map((p) => p.contract.conId)).toEqual([1]) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts index 798541ce74..731bd503dd 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -444,6 +444,26 @@ export class RequestBridge extends DefaultEWrapper { // ---- Account subscription callbacks (persistent cache) ---- + /** + * Upsert-by-conId into a position list; null row = remove. + * TWS sends DELTAS between accountDownloadEnd markers (a fill updates one + * position immediately, the next full download can be ~3 minutes away) — + * so updates must apply to BOTH the live cache (immediate visibility) and + * the pending rebuild buffer (next swap must not resurrect stale rows). + * Keying by conId also prevents duplicate rows when the same position + * updates repeatedly within one batch window (price ticks do this). + */ + private upsertPosition(list: AccountDownloadResult['positions'], contract: Contract, row: AccountDownloadResult['positions'][number] | null): void { + const idx = list.findIndex((p) => p.contract.conId === contract.conId) + if (row === null) { + if (idx >= 0) list.splice(idx, 1) + } else if (idx >= 0) { + list[idx] = row + } else { + list.push(row) + } + } + override updatePortfolio( contract: Contract, position: Decimal, @@ -454,10 +474,10 @@ export class RequestBridge extends DefaultEWrapper { realizedPNL: string, _accountName: string, ): void { - if (!this.accountCachePending_) return - if (position.isZero()) return - - this.accountCachePending_.positions.push(buildPosition({ + // Zero quantity = position fully closed — must REMOVE from cache, not + // be ignored (a closed position used to linger until the next full + // download). + const row = position.isZero() ? null : buildPosition({ contract, currency: contract.currency || 'USD', side: position.greaterThan(0) ? 'long' : 'short', @@ -471,11 +491,16 @@ export class RequestBridge extends DefaultEWrapper { unrealizedPnL: unrealizedPNL, realizedPnL: realizedPNL, multiplier: contract.multiplier || '1', - })) + }) + + if (this.accountCachePending_) this.upsertPosition(this.accountCachePending_.positions, contract, row) + if (this.accountCache_) this.upsertPosition(this.accountCache_.positions, contract, row) } override updateAccountValue(key: string, val: string, _currency: string, _accountName: string): void { this.accountCachePending_?.values.set(key, val) + // Deltas must be visible immediately, not at the next downloadEnd swap. + this.accountCache_?.values.set(key, val) } override accountDownloadEnd(_accountName: string): void { @@ -504,12 +529,20 @@ export class RequestBridge extends DefaultEWrapper { const snap = this.snapshots.get(reqId) if (!snap) return + // Delayed variants (66-73) arrive instead of live ticks when the + // account has no live subscription and reqMarketDataType(3) is set — + // paper accounts live here. Same field, 15-min-delayed value. switch (tickType) { - case TickTypeEnum.BID: snap.bid = price; break - case TickTypeEnum.ASK: snap.ask = price; break - case TickTypeEnum.LAST: snap.last = price; break - case TickTypeEnum.HIGH: snap.high = price; break - case TickTypeEnum.LOW: snap.low = price; break + case TickTypeEnum.BID: + case TickTypeEnum.DELAYED_BID: snap.bid = price; break + case TickTypeEnum.ASK: + case TickTypeEnum.DELAYED_ASK: snap.ask = price; break + case TickTypeEnum.LAST: + case TickTypeEnum.DELAYED_LAST: snap.last = price; break + case TickTypeEnum.HIGH: + case TickTypeEnum.DELAYED_HIGH: snap.high = price; break + case TickTypeEnum.LOW: + case TickTypeEnum.DELAYED_LOW: snap.low = price; break } } @@ -517,7 +550,7 @@ export class RequestBridge extends DefaultEWrapper { const snap = this.snapshots.get(reqId) if (!snap) return - if (tickType === TickTypeEnum.VOLUME) { + if (tickType === TickTypeEnum.VOLUME || tickType === TickTypeEnum.DELAYED_VOLUME) { snap.volume = size.toNumber() } } From afb8076d11e6886e887a35e6e47c48ddba66890e Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 13 Jun 2026 00:10:47 +0800 Subject: [PATCH 3/6] fix(uta,ibkr): aliceId-expansion numeric-default clobber + 10xxx error routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 follow-up, chased through the market-data entitlement session: 1. _expandAliceIdIfNeeded overlay copied Contract NUMERIC defaults: the HTTP route wraps bodies with Object.assign(new Contract(), body), and while string defaults ('') were skipped, conId=0 was copied over the expanded conId — the broker received an all-empty contract and TWS rejected with 321. The by-conId quote path was dead in production while direct broker calls worked (why the probe and prod disagreed). Numeric Contract fields carry no signal at 0 / UNSET sentinels — skip them like string defaults. 2. request-bridge error routing swallowed every code >= 2000 as "informational". The farm-status band is 2100-2200; the blanket also ate the 10xxx REAL errors (10089 needs-subscription, 10197 competing live session), so pending market-data requests died as context-free timeouts. Agents now get the venue's actionable message. Live-verified: by-conId AAPL quote through the production CLI now surfaces "IBKR error 10089: ... requires additional subscription" with the full venue text (entitlement is account-side: live-account US stock market data subscription + paper sharing — sharing toggle alone gave FX live ticks but not stocks). Co-Authored-By: Claude Fable 5 --- .../trading/UnifiedTradingAccount.spec.ts | 64 ++++++++++++++++++- .../domain/trading/UnifiedTradingAccount.ts | 9 ++- .../brokers/ibkr/request-bridge.spec.ts | 18 ++++++ .../trading/brokers/ibkr/request-bridge.ts | 8 ++- 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts index aeee53d4a8..2b68972ef0 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import Decimal from 'decimal.js' -import { Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' +import { Contract, Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' import { UnifiedTradingAccount } from './UnifiedTradingAccount.js' import type { UnifiedTradingAccountOptions } from './UnifiedTradingAccount.js' import { MockBroker, makeContract, makePosition, makeOpenOrder } from './brokers/mock/index.js' @@ -299,6 +299,68 @@ describe('UTA — getAccount PnL invariant', () => { }) }) +// ==================== aliceId expansion overlay ==================== + +describe('UTA — _expandAliceIdIfNeeded overlay (via getQuote)', () => { + it('does not clobber resolved fields with Contract numeric defaults (conId=0)', async () => { + // Regression (IBKR round 7): the HTTP route wraps the body with + // Object.assign(new Contract(), body) — string defaults ('') were + // skipped by the overlay, but conId=0 was copied and CLOBBERED the + // expanded conId. The broker got an all-empty contract → TWS 321. + const broker = new MockBroker() + const seen: Contract[] = [] + broker.resolveNativeKey = (nativeKey: string) => { + const c = new Contract() + c.conId = 12087792 + c.symbol = 'EUR' + c.secType = 'CASH' + c.exchange = 'IDEALPRO' + c.currency = 'USD' + void nativeKey + return c + } + const origQuote = broker.getQuote.bind(broker) + broker.getQuote = async (c: Contract) => { + seen.push(c) + return origQuote(makeContract({ symbol: 'EUR' })) + } + const uta = new UnifiedTradingAccount(broker) + + // Route-style stub: aliceId only, every other field at Contract defaults + const stub = Object.assign(new Contract(), { aliceId: 'mock-paper|12087792' }) + await uta.getQuote(stub) + + expect(seen).toHaveLength(1) + expect(seen[0].conId).toBe(12087792) + expect(seen[0].symbol).toBe('EUR') + expect(seen[0].exchange).toBe('IDEALPRO') + }) + + it('still applies caller overrides that carry real values', async () => { + const broker = new MockBroker() + const seen: Contract[] = [] + broker.resolveNativeKey = () => { + const c = new Contract() + c.conId = 42 + c.symbol = 'AAPL' + c.exchange = 'SMART' + return c + } + const origQuote = broker.getQuote.bind(broker) + broker.getQuote = async (c: Contract) => { + seen.push(c) + return origQuote(makeContract({ symbol: 'AAPL' })) + } + const uta = new UnifiedTradingAccount(broker) + + const stub = Object.assign(new Contract(), { aliceId: 'mock-paper|42', exchange: 'NASDAQ' }) + await uta.getQuote(stub) + + expect(seen[0].conId).toBe(42) + expect(seen[0].exchange).toBe('NASDAQ') // real override survives + }) +}) + // ==================== stagePlaceOrder ==================== describe('UTA — stagePlaceOrder', () => { diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index 96775cc105..2fc1558815 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -8,7 +8,7 @@ */ import Decimal from 'decimal.js' -import { Contract, Order, ContractDescription, ContractDetails, UNSET_DECIMAL } from '@traderalice/ibkr' +import { Contract, Order, ContractDescription, ContractDetails, UNSET_DECIMAL, UNSET_INTEGER, UNSET_DOUBLE } from '@traderalice/ibkr' import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo, type UTAReach, type UTATier, type TpSlParams, type Bar, type BarParams } from './brokers/types.js' const REACH_RANK: Record = { down: 0, connected: 1, readable: 2 } @@ -955,6 +955,13 @@ export class UnifiedTradingAccount { const value = src[key] if (key === 'aliceId') continue if (value === undefined || value === '' || value === null) continue + // Numeric defaults are defaults too: `new Contract()` sets conId=0 and + // sentinel numbers (UNSET_DOUBLE/UNSET_INTEGER) on numeric fields. A + // blanket copy clobbered the expanded conId back to 0 — the broker got + // an all-empty contract and TWS rejected with error 321 (the by-conId + // quote path was dead in production while direct broker calls worked). + // No numeric Contract field carries signal at 0 or at a sentinel. + if (typeof value === 'number' && (value === 0 || value === UNSET_INTEGER || value === UNSET_DOUBLE)) continue dst[key] = value } return expanded diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts index aa6d5b2c6a..ea17976b4f 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts @@ -16,6 +16,24 @@ function pushUpdate(b: RequestBridge, contract: Contract, qty: number, avgCost = b.updatePortfolio(contract, new Decimal(qty), '101', String(qty * 101), avgCost, '1', '0', 'DU1') } +describe('RequestBridge — error routing', () => { + it('routes 10xxx errors into the pending request (no silent timeout)', async () => { + // Regression: `errorCode >= 2000` swallowed 10089 (market data needs + // subscription) — the snapshot promise timed out with zero context + // instead of carrying the venue's actionable message. + const b = new RequestBridge() + const promise = b.requestSnapshot(9001, 5000) + b.error(9001, 0, 10089, 'Requested market data requires additional subscription for API.') + await expect(promise).rejects.toThrow(/subscription/) + }) + + it('still ignores 21xx farm-status noise', () => { + const b = new RequestBridge() + // no pending request — must simply not throw + expect(() => b.error(-1, 0, 2104, 'Market data farm connection is OK')).not.toThrow() + }) +}) + /** * TWS account-subscription semantics: full download bursts end with * accountDownloadEnd; between bursts TWS pushes DELTAS with no end marker diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts index 731bd503dd..ddd46fd956 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -387,8 +387,12 @@ export class RequestBridge extends DefaultEWrapper { // ---- Error routing ---- override error(reqId: number, _errorTime: number, errorCode: number, errorString: string): void { - // Informational messages (code >= 2000) — data farm status, etc. - if (errorCode >= 2000) return + // Informational warnings live in the 2100-2200 band (data farm + // status etc.). The old `>= 2000` blanket also swallowed the 10xxx + // REAL errors (10089 no-subscription, 10197 competing session...) — + // pending requests then died as useless timeouts instead of carrying + // the venue's actionable message. + if (errorCode >= 2100 && errorCode < 2200) return // System-level errors (reqId === -1) — connectivity events if (reqId === NO_VALID_ID) { From 6a365ceecec49e745fec12fb5bb7622f873ae41c Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 13 Jun 2026 00:47:22 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(uta,ibkr):=20hub/leaf=20contract=20ide?= =?UTF-8?q?ntity=20=E2=80=94=20bond=20chains,=20option=20chains,=20FX=20fa?= =?UTF-8?q?milies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design session outcome (with Ame): venue search returns two species — LEAVES (tradeable contracts) and HUBS (directories: a bond issuer, an FX currency family, an option chain behind a stock). The old identity layer only modeled leaves; FX/BOND search rows either mis-resolved (symbol nativeKey hardcoded STK → "EUR" the stock) or died as unaddressable noise. Rather than excluding asset classes one by one, the hub/leaf structure is now first-class and extensible: - nativeKey grammar (IBKR): conId = canonical leaf for EVERYTHING tradeable (bonds included — uniqueness doctrine intact); issuer:eXXX = bond-issuer directory (addressable, loudly NOT tradeable); bare symbol stays an STK convenience. resolveNativeKey refuses directory keys with an actionable message instead of silently assuming STK. - expandContract (protocol + IBroker optional + UTA + route + SDK + tool + CLI `contract expand`): issuer hub → individual bonds; underlying → option parameter grid (expirations × strikes via reqSecDefOptParams); underlying + expiry/right/strike window → concrete option contracts; secType=FUT → futures months. Every leaf out carries its own conId aliceId. No silent truncation (total + hint). - Search: CASH family rows auto-expand inline into concrete pairs (".USD" pattern suffix narrows); BOND issuer rows surface as expandable hubs with the issuer name. - Fixes en route: getContractDetails SMART/USD defaults poisoned conId and non-STK queries (EUR.USD@IDEALPRO → error 200) — defaults now STK-only; getQuote applied defaults BEFORE conId enrichment (same poison, production-only); bondContractDetails callback was unrouted (bond chains returned empty); compactContract drops meaningless strike '0'. Live-verified on TWS paper: EUR.USD search → conId aliceId → first production IBKR quote (bid/ask 1.1573@IDEALPRO); IBM issuer hub → 65 bonds, each conId-addressable; AAPL grid (26 expirations × 123 strikes) → 5 concrete Jul-2026 calls → order lifecycle smoke (deep LMT placed, tracked with conId aliceId, cancelled clean). Option quotes refuse loudly with the venue's entitlement message (10091). Catalog gains S13 (hub/leaf identity) + checklist items. Known polish gaps (daylight work): bond leaves' coupon/maturity labels come through empty (decoder field investigation); Quote.last fabricates '0' when the venue reports none (type change touches all brokers). Co-Authored-By: Claude Fable 5 --- docs/uta-live-testing.md | 17 +- packages/uta-protocol/src/types/broker.ts | 51 +++++ .../domain/trading/UnifiedTradingAccount.ts | 25 ++- .../trading/brokers/ibkr/IbkrBroker.spec.ts | 36 ++++ .../domain/trading/brokers/ibkr/IbkrBroker.ts | 193 +++++++++++++++++- .../trading/brokers/ibkr/request-bridge.ts | 30 +++ services/uta/src/http/routes-trading.ts | 13 ++ src/server/cli-commands.ts | 1 + src/services/uta-client/UTAAccountSDK.ts | 10 + src/tool/trading-compact.ts | 4 +- src/tool/trading.ts | 68 +++++- 11 files changed, 435 insertions(+), 13 deletions(-) diff --git a/docs/uta-live-testing.md b/docs/uta-live-testing.md index 284186408a..a9aee1864d 100644 --- a/docs/uta-live-testing.md +++ b/docs/uta-live-testing.md @@ -158,7 +158,22 @@ dead ends.* (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.* ## Scoreboard so far diff --git a/packages/uta-protocol/src/types/broker.ts b/packages/uta-protocol/src/types/broker.ts index b7388b49c6..9a99843626 100644 --- a/packages/uta-protocol/src/types/broker.ts +++ b/packages/uta-protocol/src/types/broker.ts @@ -128,6 +128,49 @@ export interface PlaceOrderResult { 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(). */ export interface OpenOrder { contract: Contract @@ -348,6 +391,14 @@ export interface IBroker { searchContracts(pattern: string): Promise getContractDetails(query: Contract): Promise + /** + * 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 + /** * Refresh the broker's local catalog cache from upstream. * Optional — only EnumeratingCatalog brokers (Alpaca / CCXT / Mock) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index 2fc1558815..4d9329246c 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -9,7 +9,7 @@ import Decimal from 'decimal.js' import { Contract, Order, ContractDescription, ContractDetails, UNSET_DECIMAL, UNSET_INTEGER, UNSET_DOUBLE } from '@traderalice/ibkr' -import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo, type UTAReach, type UTATier, type TpSlParams, type Bar, type BarParams } from './brokers/types.js' +import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo, type UTAReach, type UTATier, type TpSlParams, type Bar, type BarParams, type ExpandContractFilters, type ContractExpansion } from './brokers/types.js' const REACH_RANK: Record = { down: 0, connected: 1, readable: 2 } import { TradingGit } from './git/TradingGit.js' @@ -910,6 +910,29 @@ export class UnifiedTradingAccount { return this._callBroker(() => this.broker.getMarketClock()) } + /** + * Hub → leaves expansion (bond issuers, option chains, futures months). + * Loud-refuses when the broker has no hub semantics. Accepts a full + * aliceId; hub keys (issuer:…) are passed to the broker verbatim — they + * deliberately do NOT go through resolveNativeKey, which refuses them + * (directories are not tradeable contracts). + */ + async expandContract(aliceId: string, filters?: ExpandContractFilters): Promise { + if (typeof this.broker.expandContract !== 'function') { + throw new BrokerError('CONFIG', `Account "${this.label}" does not support contract expansion.`) + } + const parsed = UnifiedTradingAccount.parseAliceId(aliceId) + if (!parsed) { + throw new Error(`Invalid aliceId "${aliceId}" — expected format: accountId|nativeKey`) + } + if (parsed.utaId !== this.id) { + throw new Error(`aliceId "${aliceId}" belongs to UTA "${parsed.utaId}", not "${this.id}".`) + } + const result = await this._callBroker(() => this.broker.expandContract!(parsed.nativeKey, filters)) + for (const c of result.contracts ?? []) this.stampAliceId(c) + return result + } + async searchContracts(pattern: string): Promise { const results = await this._callBroker(() => this.broker.searchContracts(pattern)) for (const desc of results) this.stampAliceId(desc.contract) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts index 770d76b6ef..c6cd43e576 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -53,3 +53,39 @@ describe('IbkrBroker — attached TP/SL refusal gate', () => { }).not.toThrow(/gate tripped/) }) }) + +describe('IbkrBroker — nativeKey grammar (hub/leaf identity)', () => { + // conId = canonical leaf; issuer: = bond-issuer directory; bare symbol = + // STK convenience. Hubs must REFUSE resolution (directories aren't + // tradeable) instead of the old silent assume-STK. + it('getNativeKey prefers conId, falls back to issuer: for bond hubs, then symbol', () => { + const b = bareBroker() + + const leaf = new Contract() + leaf.conId = 265598 + leaf.symbol = 'AAPL' + expect(b.getNativeKey(leaf)).toBe('265598') + + const bondHub = new Contract() + bondHub.secType = 'BOND' + bondHub.issuerId = 'e1400789' + expect(b.getNativeKey(bondHub)).toBe('issuer:e1400789') + + const symbolOnly = new Contract() + symbolOnly.symbol = 'AAPL' + expect(b.getNativeKey(symbolOnly)).toBe('AAPL') + }) + + it('resolveNativeKey refuses issuer: directories with an actionable message', () => { + const b = bareBroker() + expect(() => b.resolveNativeKey('issuer:e1400789')).toThrow(/directory.*expand|expand.*directory/i) + }) + + it('resolveNativeKey round-trips conId and keeps the STK symbol convenience', () => { + const b = bareBroker() + expect(b.resolveNativeKey('265598').conId).toBe(265598) + const sym = b.resolveNativeKey('AAPL') + expect(sym.symbol).toBe('AAPL') + expect(sym.secType).toBe('STK') + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index ad5121b8a6..ccd1971095 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -19,7 +19,7 @@ import { Order, OrderCancel, OrderState, - type ContractDescription, + ContractDescription, type ContractDetails, } from '@traderalice/ibkr' import { @@ -34,6 +34,8 @@ import { type MarketClock, type BrokerConfigField, type TpSlParams, + type ExpandContractFilters, + type ContractExpansion, } from '../types.js' import '../../contract-ext.js' import { aggregateAccountFromPositions } from '../../position-math.js' @@ -134,23 +136,177 @@ export class IbkrBroker implements IBroker { // ==================== Contract search ==================== + /** + * Symbol search, hub-aware. TWS's reqMatchingSymbols returns ENTITIES, not + * always contracts: stock rows carry their conId (1:1 with a contract), but + * FX rows are a currency FAMILY (conId=0, no quote currency yet) and BOND + * rows are an ISSUER directory (conId=0, identity = issuerId). Leaves pass + * through; CASH hubs are expanded inline into concrete pairs (small + * fan-out, optionally narrowed by a ".USD" pattern suffix); BOND issuer + * hubs pass through and become `issuer:` aliceIds (expand explicitly). + */ async searchContracts(pattern: string): Promise { if (!pattern) return [] const reqId = this.bridge.allocReqId() const promise = this.bridge.request(reqId) - this.client.reqMatchingSymbols(reqId, pattern) - return promise + // TWS matches on the base symbol — strip an FX-style ".USD" suffix for + // the request, keep it as a pair filter for the expansion below. + const dot = pattern.indexOf('.') + const base = dot > 0 ? pattern.slice(0, dot) : pattern + const pairCurrency = dot > 0 ? pattern.slice(dot + 1).toUpperCase() : '' + this.client.reqMatchingSymbols(reqId, base) + const rows = await promise + + const out: ContractDescription[] = [] + for (const row of rows) { + const c = row.contract + if (c.secType === 'CASH' && !c.conId) { + out.push(...await this.expandCashHub(c, pairCurrency)) + continue + } + // Everything else passes through — leaves carry conId; BOND issuer + // hubs carry issuerId (addressable, not tradeable); anything without + // either stays visible rather than being silently dropped. + out.push(row) + } + return out + } + + /** CASH family row → concrete currency pairs (each with its own conId). */ + private async expandCashHub(hub: Contract, pairCurrency: string): Promise { + const q = new Contract() + q.symbol = hub.symbol + q.secType = 'CASH' + if (pairCurrency) q.currency = pairCurrency + try { + const details = await this.contractDetailsQuery(q) + return details.map((d) => { + const cd = new ContractDescription() + cd.contract = d.contract + cd.derivativeSecTypes = [] + return cd + }) + } catch (err) { + console.warn(`IbkrBroker[${this.id}]: CASH hub expansion failed for ${hub.symbol}: ${err instanceof Error ? err.message : err}`) + return [] + } } async getContractDetails(query: Contract): Promise { - if (!query.exchange) query.exchange = 'SMART' - if (!query.currency) query.currency = 'USD' + const results = await this.contractDetailsQuery(query) + return results[0] ?? null + } + + /** All matching contract details (a conId resolves to one; a family query + * like EUR/CASH or an issuerId resolves to many). */ + private async contractDetailsQuery(query: Contract): Promise { + // Routing defaults are for SYMBOL-form STK queries only. A conId (or + // issuerId) resolves globally, and non-STK secTypes don't live on SMART + // (EUR.USD is on IDEALPRO; conId+SMART → TWS error 200, found live). + // Forcing USD would also narrow a CASH family query to one pair. + if (!query.conId && !query.issuerId && (!query.secType || query.secType === 'STK')) { + if (!query.exchange) query.exchange = 'SMART' + if (!query.currency) query.currency = 'USD' + } const reqId = this.bridge.allocReqId() const promise = this.bridge.requestCollector(reqId) this.client.reqContractDetails(reqId, query) - const results = await promise - return results[0] ?? null + return promise + } + + /** + * Hub → leaves expansion (see nativeKey grammar at getNativeKey): + * issuer:eXXX → the issuer's individual bonds (each conId-keyed) + * (no expiry) → option-chain parameter grid for the underlying + * + expiry → concrete option contracts for that expiry + * secType=FUT → futures contract months + */ + async expandContract(nativeKey: string, filters: ExpandContractFilters = {}): Promise { + const limit = Math.max(1, Math.min(filters.limit ?? 60, 200)) + + if (nativeKey.startsWith('issuer:')) { + const q = new Contract() + q.secType = 'BOND' + q.issuerId = nativeKey.slice('issuer:'.length) + const details = await this.contractDetailsQuery(q) + // A bond Contract's own fields are opaque (localSymbol "IBCID…") — + // the human identity (coupon, maturity) lives on ContractDetails. + const all = details.map((d) => { + const c = d.contract + if (!c.description) { + const coupon = d.coupon ? `${d.coupon}%` : '' + const maturity = d.maturity ? ` ${d.maturity}` : '' + const label = `${coupon}${maturity}`.trim() + if (label) c.description = label + } + if (d.maturity && !c.lastTradeDateOrContractMonth) c.lastTradeDateOrContractMonth = d.maturity + return c + }) + all.sort((a, b) => (a.lastTradeDateOrContractMonth || '').localeCompare(b.lastTradeDateOrContractMonth || '')) + return { + kind: 'contracts', + contracts: all.slice(0, limit), + total: all.length, + ...(all.length > limit ? { hint: `${all.length} bonds match; showing the first ${limit}. Raise limit to see more.` } : {}), + } + } + + const asNum = parseInt(nativeKey, 10) + if (isNaN(asNum) || String(asNum) !== nativeKey) { + throw new BrokerError('EXCHANGE', + `Cannot expand "${nativeKey}" — expansion takes a conId aliceId (an underlying from search) or an issuer: directory key.`) + } + const underlying = await this.getContractDetails(Object.assign(new Contract(), { conId: asNum })) + if (!underlying?.contract) { + throw new BrokerError('EXCHANGE', `conId ${asNum} did not resolve to a contract`) + } + const u = underlying.contract + + const famSecType = filters.secType ?? 'OPT' + if (famSecType === 'FUT' || filters.expiry) { + // Concrete leaves for one family/expiry + const q = new Contract() + q.symbol = u.symbol + q.secType = famSecType + q.currency = u.currency + if (famSecType === 'OPT') q.exchange = 'SMART' + if (filters.expiry) q.lastTradeDateOrContractMonth = filters.expiry + if (filters.right) q.right = filters.right + const details = await this.contractDetailsQuery(q) + let all = details.map((d) => d.contract) + if (filters.strikeMin != null) all = all.filter((c) => c.strike >= filters.strikeMin!) + if (filters.strikeMax != null) all = all.filter((c) => c.strike <= filters.strikeMax!) + all.sort((a, b) => + (a.lastTradeDateOrContractMonth || '').localeCompare(b.lastTradeDateOrContractMonth || '') + || (a.strike - b.strike) + || (a.right || '').localeCompare(b.right || '')) + return { + kind: 'contracts', + contracts: all.slice(0, limit), + total: all.length, + ...(all.length > limit ? { hint: `${all.length} contracts match; showing the first ${limit}. Narrow with right/strikeMin/strikeMax or raise limit.` } : {}), + } + } + + // OPT without expiry → parameter grid (expirations × strikes per exchange) + const reqId = this.bridge.allocReqId() + const promise = this.bridge.requestCollector<{ + exchange: string; underlyingConId: number; tradingClass: string; + multiplier: string; expirations: string[]; strikes: number[] + }>(reqId) + this.client.reqSecDefOptParams(reqId, u.symbol, '', u.secType, u.conId) + const grids = await promise + if (grids.length === 0) { + return { kind: 'optionGrid', grid: [], hint: `No option chain found for ${u.symbol}.` } + } + // SMART grid first — it aggregates the listings an order would route to. + grids.sort((a, b) => Number(b.exchange === 'SMART') - Number(a.exchange === 'SMART')) + return { + kind: 'optionGrid', + grid: grids, + hint: 'Pick an expiry (and optionally right / strike range), then expand again with expiry to get tradeable contracts.', + } } // ==================== Trading operations ==================== @@ -388,8 +544,9 @@ export class IbkrBroker implements IBroker { private readonly conIdContracts = new Map() async getQuote(contract: Contract): Promise { - if (!contract.exchange) contract.exchange = 'SMART' - if (!contract.currency) contract.currency = 'USD' + // Enrichment must run BEFORE routing defaults: a premature SMART poisons + // the conId details lookup for anything not on SMART (EUR.USD@IDEALPRO). + // The enriched contract carries its real exchange/currency. // TWS rejects reqMktData on a bare conId (error 321: symbol/localSymbol/ // secId required) even though the wire carries conId — resolution by @@ -407,6 +564,9 @@ export class IbkrBroker implements IBroker { contract = full } + if (!contract.exchange) contract.exchange = 'SMART' + if (!contract.currency) contract.currency = 'USD' + const reqId = this.bridge.allocReqId() const promise = this.bridge.requestSnapshot(reqId) this.client.reqMktData(reqId, contract, '', true, false, []) @@ -466,13 +626,28 @@ export class IbkrBroker implements IBroker { // ==================== Contract identity ==================== + /** + * IBKR nativeKey grammar (the broker's uniqueness primitives, layered): + * "265598" conId — canonical for every tradeable contract + * "issuer:e1400789" bond-issuer DIRECTORY — addressable, NOT tradeable + * "AAPL" bare symbol — STK convenience for hand-typed ids + * Hubs (directories) live in their own prefixed namespace so trading + * surfaces can refuse them loudly instead of mis-resolving. + */ getNativeKey(contract: Contract): string { // conId is IBKR's globally unique contract identifier if (contract.conId) return String(contract.conId) + if (contract.secType === 'BOND' && contract.issuerId) return `issuer:${contract.issuerId}` return contract.symbol } resolveNativeKey(nativeKey: string): Contract { + if (nativeKey.startsWith('issuer:')) { + throw new Error( + `"${nativeKey}" is a bond-issuer directory, not a tradeable contract — ` + + `expand it (contract expand) to list the issuer's individual bonds; each bond has its own conId aliceId.`, + ) + } const c = new Contract() const asNum = parseInt(nativeKey, 10) if (!isNaN(asNum) && String(asNum) === nativeKey) { diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts index ddd46fd956..9af0501a16 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -425,10 +425,40 @@ export class RequestBridge extends DefaultEWrapper { this.pushCollector(reqId, cd) } + // Bonds arrive via their own callback (TWS quirk) — same collector. + override bondContractDetails(reqId: number, cd: ContractDetails): void { + this.pushCollector(reqId, cd) + } + override contractDetailsEnd(reqId: number): void { this.resolveCollector(reqId) } + // ---- Option chain parameters (collector) ---- + + override securityDefinitionOptionParameter( + reqId: number, + exchange: string, + underlyingConId: number, + tradingClass: string, + multiplier: string, + expirations: Set, + strikes: Set, + ): void { + this.pushCollector(reqId, { + exchange, + underlyingConId, + tradingClass, + multiplier, + expirations: [...expirations].sort(), + strikes: [...strikes].sort((a, b) => a - b), + }) + } + + override securityDefinitionOptionParameterEnd(reqId: number): void { + this.resolveCollector(reqId) + } + // ---- Account summary (collector using Map) ---- override accountSummary(reqId: number, _account: string, tag: string, value: string, _currency: string): void { diff --git a/services/uta/src/http/routes-trading.ts b/services/uta/src/http/routes-trading.ts index 2c595758f0..e0afc27e02 100644 --- a/services/uta/src/http/routes-trading.ts +++ b/services/uta/src/http/routes-trading.ts @@ -309,6 +309,19 @@ export function createTradingRoutes(ctx: UTAEngineContext) { } }) + // Hub → leaves expansion (bond issuers, option chains, futures months). + // Body: { aliceId, filters?: ExpandContractFilters }. + app.post('/uta/:id/contract/expand', async (c) => { + const account = resolveAccount(ctx, c) + if (!account) return c.json({ error: 'Account not found' }, 404) + try { + const body = await c.req.json().catch(() => ({})) + return c.json(await account.expandContract(String(body.aliceId ?? ''), body.filters ?? {})) + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 500) + } + }) + // Historical OHLCV bars. Body: { contract: , params: BarParams }. // start/end arrive as ISO strings over the wire — revive to Date (the only // Date fields in BarParams) before the broker call. diff --git a/src/server/cli-commands.ts b/src/server/cli-commands.ts index be3e8c92ee..499839abe2 100644 --- a/src/server/cli-commands.ts +++ b/src/server/cli-commands.ts @@ -163,6 +163,7 @@ export const CLI_EXPORTS: Record = { search: 'searchContracts', details: 'getContractDetails', quote: 'getQuote', + expand: 'expandContract', }, order: { list: 'getOrders', diff --git a/src/services/uta-client/UTAAccountSDK.ts b/src/services/uta-client/UTAAccountSDK.ts index 1d9dde0c42..900509da2e 100644 --- a/src/services/uta-client/UTAAccountSDK.ts +++ b/src/services/uta-client/UTAAccountSDK.ts @@ -39,6 +39,8 @@ import type { StagePlaceOrderParams, StageModifyOrderParams, StageClosePositionParams, + ExpandContractFilters, + ContractExpansion, } from '@traderalice/uta-protocol' import type { Contract, ContractDescription, ContractDetails } from '@traderalice/ibkr' @@ -147,6 +149,14 @@ export class UTAAccountSDK { return this.client.get(`/api/trading/uta/${encodeURIComponent(this.id)}/market-clock`) } + /** Hub → leaves expansion (bond issuers, option chains, futures months). */ + expandContract(aliceId: string, filters?: ExpandContractFilters): Promise { + return this.client.post( + `/api/trading/uta/${encodeURIComponent(this.id)}/contract/expand`, + { aliceId, filters }, + ) + } + /** * Historical OHLCV bars for a contract. Mirrors `getQuote`: the body may * be a full `Contract` or an `{ aliceId }` hint, expanded server-side via diff --git a/src/tool/trading-compact.ts b/src/tool/trading-compact.ts index 0b19e53397..b40bb6035c 100644 --- a/src/tool/trading-compact.ts +++ b/src/tool/trading-compact.ts @@ -81,7 +81,9 @@ export function compactContract(c: unknown): AnyRec { pick(out, 'exchange', val(k['exchange'])) pick(out, 'description', val(k['description'])) pick(out, 'expiry', val(k['lastTradeDateOrContractMonth'])) - pick(out, 'strike', val(k['strike'])) + // strike 0 = "not an option" — carries no signal, drop like a sentinel + const strike = val(k['strike']) + if (strike && strike !== '0') out['strike'] = strike const right = val(k['right']) if (right === 'C' || right === 'CALL') out['right'] = 'C' else if (right === 'P' || right === 'PUT') out['right'] = 'P' diff --git a/src/tool/trading.ts b/src/tool/trading.ts index a5a27d8a2b..75ccdc2bf6 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -158,6 +158,12 @@ export function createTradingTools(manager: UTAManagerSDK): Record description: `Search broker accounts for tradeable contracts matching a pattern. This is a BROKER-LEVEL search — it queries your connected trading accounts. +Results are either LEAVES (tradeable, use aliceId with getQuote/placeOrder) or +DIRECTORIES (expandable: true — e.g. a bond issuer): call expandContract on +those to list the tradeable contracts inside. Stock rows with +derivativeSecTypes (OPT/FUT…) can also be expanded into their option chain / +futures months via expandContract. + Pass \`assetClass\` when known (especially "crypto" or "currency") so the data-vendor symbol is normalized into a broker-friendly pattern — e.g. a search for "BTCUSD" with assetClass="crypto" is rewritten to "BTC" before @@ -183,7 +189,14 @@ hitting the broker, which otherwise expects the bare base ticker.`, ) for (const r of settled) { if (r.status !== 'fulfilled') continue - for (const desc of r.value.results) all.push({ source: r.value.id, ...desc, contract: compactContract((desc as { contract?: unknown }).contract) }) + for (const desc of r.value.results) { + const contract = compactContract((desc as { contract?: unknown }).contract) + // Directory rows (bond issuers, …) are addressable but NOT + // tradeable — flag them so the agent reaches for expandContract + // instead of placeOrder. + const expandable = typeof contract['aliceId'] === 'string' && (contract['aliceId'] as string).includes('|issuer:') + all.push({ source: r.value.id, ...desc, contract, ...(expandable ? { expandable: true } : {}) }) + } } if (all.length === 0) return { results: [], message: `No contracts found matching "${brokerPattern}" (input: "${pattern}").` } return all @@ -388,6 +401,59 @@ If this tool returns an error with transient=true, wait a few seconds and retry }, }), + expandContract: tool({ + description: `Expand a directory-style contract into tradeable leaves. +Venue search returns two species: LEAVES (tradeable, with conId-style aliceId) and HUBS (directories). +- Bond issuer hub (aliceId like "ibkr-x|issuer:e1400789"): expands to the issuer's individual bonds. +- Stock underlying (numeric aliceId): no expiry → option-chain parameter grid (expirations × strikes); with expiry → concrete option contracts for that expiry. +- secType=FUT on an underlying: futures contract months. +Every returned leaf carries its own aliceId usable with getQuote / placeOrder.`, + inputSchema: z.object({ + aliceId: z.string().describe('Hub or underlying contract ID (format: accountId|nativeKey, from searchContracts)'), + expiry: z.string().optional().describe('Expiry YYYYMMDD or YYYYMM — switches option expansion from the grid to concrete contracts'), + right: z.enum(['C', 'P']).optional().describe('Option right filter'), + strikeMin: z.number().optional().describe('Lowest strike to include'), + strikeMax: z.number().optional().describe('Highest strike to include'), + secType: z.enum(['OPT', 'FUT']).optional().describe('Derivative family to expand on an underlying (default OPT)'), + limit: z.number().int().positive().optional().describe('Max leaves returned (default 60). total always reports the full count.'), + }).meta({ examples: [ + { aliceId: 'ibkr-tws|265598' }, + { aliceId: 'ibkr-tws|265598', expiry: '20260717', right: 'C', strikeMin: 280, strikeMax: 310 }, + { aliceId: 'ibkr-tws|issuer:e1400789' }, + ] }), + execute: async ({ aliceId, ...filters }) => { + const parsed = parseAliceId(aliceId) + if (!parsed) { + return { error: `Invalid aliceId "${aliceId}". Expected format: "accountId|nativeKey".` } + } + try { + const uta = await manager.resolveOne(parsed.utaId) + const result = await uta.expandContract(aliceId, filters) + if (result.kind === 'contracts') { + return { + source: uta.id, + total: result.total, + contracts: (result.contracts ?? []).map(compactContract), + ...(result.hint ? { hint: result.hint } : {}), + } + } + return { + source: uta.id, + grid: (result.grid ?? []).map((g) => ({ + exchange: g.exchange, + tradingClass: g.tradingClass, + multiplier: g.multiplier, + expirations: g.expirations, + strikes: g.strikes, + })), + ...(result.hint ? { hint: result.hint } : {}), + } + } catch (err) { + return handleBrokerError(err) + } + }, + }), + getMarketClock: tool({ description: `Get current market clock status (isOpen, nextOpen, nextClose). If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, From c277433dbd67f797da37d455d6a4e5821a26a34d Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 13 Jun 2026 01:38:54 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(ibkr,ui):=20community-issue=20sweep=20?= =?UTF-8?q?=E2=80=94=20currency-keyed=20account=20values=20(#295),=20mixed?= =?UTF-8?q?-book=20valuation=20(#314/ANG-101),=20dead-connection=20gate=20?= =?UTF-8?q?(#294),=20symbol-first=20display=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage pass over open UTA issues, live-verified on the running TWS paper account (mixed HKD+USD book — exactly the configuration the reporters describe): #295 — updateAccountValue ignored the currency argument; multi-currency tag families (CashBalance, NetLiquidationByCurrency, ExchangeRate, …) arrive once per currency + a consolidated BASE line, and last-write-wins left a random currency in the plain key. Values are now stored under key:currency composites; BASE owns the plain key and a per-currency line can never overwrite it. #314 + ANG-101 — IbkrBroker.getAccount blind-summed per-position unrealizedPnL and marketValue across currencies (live numbers: HKD -4767.62 + USD +368.80 reported as USD -4398.82; netLiq inflated $39.6k by counting 46.4k HKD as USD). TWS hands us per-currency ExchangeRate tags — position math is now rate-converted; mixed books prefer TWS's own consolidated NetLiquidation tag (authority + FX correctness) with rate-converted reconstruction as fallback; same- currency books keep the fresher position-derived reconstruction. Live: netLiq 1,085,714 → 1,046,098 (TWS says 1,046,101), uPnL -4,398 → -305.71. #314's duplicate-position complaint was already fixed by tonight's upsert-by-conId. #294 — a silently-dead Gateway socket kept health green because the IBKR account surface is cache-backed (no query ever touches the socket). Added: 45s reqCurrentTime heartbeat that marks the connection dead on timeout; connectionClosed marks dead immediately; cache reads and order paths loud-refuse while dead (orders could previously be accepted and never transmit); init() force-tears-down a half-open socket so the recovery loop's re-init actually reconnects. #208 — orders/positions tables rendered the internal aliceId before the symbol. Symbol first, aliceId moved to the tooltip. 1934 tests green (+10 regression specs). Co-Authored-By: Claude Fable 5 --- .../trading/brokers/ibkr/IbkrBroker.spec.ts | 61 +++++++++++++ .../domain/trading/brokers/ibkr/IbkrBroker.ts | 87 +++++++++++++++++-- .../brokers/ibkr/request-bridge.spec.ts | 35 ++++++++ .../trading/brokers/ibkr/request-bridge.ts | 27 +++++- ui/src/pages/TradingPage.tsx | 2 +- ui/src/pages/UTADetailPage.tsx | 4 +- 6 files changed, 202 insertions(+), 14 deletions(-) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts index c6cd43e576..5052aa692a 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -89,3 +89,64 @@ describe('IbkrBroker — nativeKey grammar (hub/leaf identity)', () => { expect(sym.secType).toBe('STK') }) }) + +describe('IbkrBroker — getAccount mixed-currency math (ANG-101 / issues #295 #314)', () => { + function brokerWithCache(values: Record, positions: unknown[]): IbkrBroker { + const b = bareBroker() + ;(b as unknown as { bridge: unknown }).bridge = { + getAccountCache: () => ({ values: new Map(Object.entries(values)), positions }), + } + return b + } + const hkdPos = { contract: { conId: 1 }, currency: 'HKD', unrealizedPnL: '-4767.62', marketValue: '46426.72' } + const usdPos = { contract: { conId: 2 }, currency: 'USD', unrealizedPnL: '368.80', marketValue: '2913.10' } + + it('converts per-position PnL via TWS ExchangeRate tags instead of blind-summing', async () => { + const b = brokerWithCache({ + TotalCashValue: '1036370.91', NetLiquidation: '1046101.70', + 'ExchangeRate:HKD': '0.1276211', + RealizedPnL: '0', BuyingPower: '0', InitMarginReq: '0', MaintMarginReq: '0', + }, [hkdPos, usdPos]) + const a = await b.getAccount() + // -4767.62 × 0.1276211 + 368.80 = -239.66… (blind sum was -4398.82) + expect(Number(a.unrealizedPnL)).toBeCloseTo(-239.66, 1) + // Mixed book → TWS's consolidated NetLiquidation tag wins (#314) + expect(a.netLiquidation).toBe('1046101.7') + }) + + it('missing FX rate falls back to broker values, never sums garbage', async () => { + const b = brokerWithCache({ + TotalCashValue: '1036370.91', NetLiquidation: '1046101.70', UnrealizedPnL: '-240', + RealizedPnL: '0', BuyingPower: '0', InitMarginReq: '0', MaintMarginReq: '0', + }, [hkdPos, usdPos]) + const a = await b.getAccount() + expect(a.unrealizedPnL).toBe('-240') + expect(a.netLiquidation).toBe('1046101.7') + }) + + it('same-currency book keeps the reconstructed (fresher) netLiquidation', async () => { + const b = brokerWithCache({ + TotalCashValue: '1000', NetLiquidation: '99999', + RealizedPnL: '0', BuyingPower: '0', InitMarginReq: '0', MaintMarginReq: '0', + }, [{ ...usdPos, multiplier: '1', quantity: '10' }]) + const a = await b.getAccount() + expect(a.netLiquidation).not.toBe('99999') // cash + Σ marketValue, not the cached tag + }) +}) + +describe('IbkrBroker — dead-connection gate (issue #294)', () => { + it('cache-backed reads and order paths refuse loudly when the socket is known-dead', async () => { + const b = bareBroker() + ;(b as unknown as { bridge: unknown }).bridge = { connectionDead: true } + + await expect(b.getAccount()).rejects.toThrow(/connection lost/i) + await expect(b.getPositions()).rejects.toThrow(/connection lost/i) + + const { contract, order } = stkOrder() + const r = await b.placeOrder(contract, order) + // placeOrder catches and returns { success: false } — the message must + // still carry the dead-connection cause, not a generic failure. + expect(r.success).toBe(false) + expect(r.error).toMatch(/connection lost/i) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index ccd1971095..61a03fcf1a 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -94,7 +94,39 @@ export class IbkrBroker implements IBroker { // ==================== Lifecycle ==================== + /** Periodic socket probe — see _ensureAlive / issue #294. */ + private heartbeatTimer_: ReturnType | null = null + + /** Loud-refuse on a known-dead connection. The account surface is + * cache-backed, so without this gate a dead socket serves stale reads + * and accepts orders that never transmit (issue #294). */ + private _ensureAlive(): void { + if (this.bridge?.connectionDead) { + throw new BrokerError('NETWORK', + 'TWS/Gateway connection lost — reconnect pending. Cached data may be stale; orders will NOT transmit.') + } + } + + private startHeartbeat(): void { + if (this.heartbeatTimer_) clearInterval(this.heartbeatTimer_) + this.heartbeatTimer_ = setInterval(() => { + if (this.bridge.connectionDead) return + this.bridge.requestCurrentTime(5000).catch(() => { + console.warn(`IbkrBroker[${this.id}]: heartbeat failed — marking connection dead`) + this.bridge.markDead() + }) + }, 45_000) + // Don't hold the process open for the probe + this.heartbeatTimer_.unref?.() + } + async init(): Promise { + // A half-open socket still reports isConnected() — when the heartbeat + // (or connectionClosed) flagged it dead, force a teardown so the + // recovery loop's re-init actually reconnects instead of no-opping. + if (this.bridge.connectionDead && this.client.isConnected()) { + try { this.client.disconnect() } catch { /* already torn down */ } + } // Idempotent — skip if already connected (e.g. UTA re-wrapping a shared broker) if (this.client.isConnected()) return @@ -123,6 +155,8 @@ export class IbkrBroker implements IBroker { try { this.bridge.startAccountSubscription(this.accountId) await this.bridge.waitForAccountReady() + this.bridge.markAlive() + this.startHeartbeat() console.log(`IbkrBroker[${this.id}]: connected (account=${this.accountId}, host=${host}:${port}, clientId=${clientId})`) } catch (err) { throw BrokerError.from(err, 'NETWORK') @@ -130,6 +164,7 @@ export class IbkrBroker implements IBroker { } async close(): Promise { + if (this.heartbeatTimer_) { clearInterval(this.heartbeatTimer_); this.heartbeatTimer_ = null } this.bridge.stopAccountSubscription() this.client.disconnect() } @@ -331,6 +366,7 @@ export class IbkrBroker implements IBroker { if (!contract.currency) contract.currency = 'USD' try { + this._ensureAlive() const orderId = this.bridge.getNextOrderId() const promise = this.bridge.requestOrder(orderId) this.client.placeOrder(orderId, contract, order) @@ -347,6 +383,7 @@ export class IbkrBroker implements IBroker { async modifyOrder(orderId: string, changes: Partial): Promise { try { + this._ensureAlive() // IBKR modifies orders by re-calling placeOrder with the same orderId const original = await this.getOrder(orderId) if (!original) { @@ -380,6 +417,7 @@ export class IbkrBroker implements IBroker { async cancelOrder(orderId: string, orderCancel?: OrderCancel): Promise { try { + this._ensureAlive() const numericId = parseInt(orderId, 10) const promise = this.bridge.requestOrder(numericId) this.client.cancelOrder(numericId, orderCancel ?? new OrderCancel()) @@ -434,26 +472,58 @@ export class IbkrBroker implements IBroker { * Data Channels"). During overnight hours, the reconstructed netLiq * will be stale even though Blue Ocean ATS prices may be moving. */ + /** TWS-provided FX rate (currency → base) from the ExchangeRate account + * tags. Returns null when TWS didn't send one for this currency. */ + private fxRate(values: Map, currency: string): Decimal | null { + const raw = values.get(`ExchangeRate:${currency}`) + if (raw == null) return null + try { return new Decimal(raw) } catch { return null } + } + async getAccount(): Promise { + this._ensureAlive() const download = this.bridge.getAccountCache() if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') + const baseCurrency = download.values.get('BaseCurrency') ?? 'USD' const totalCashValue = new Decimal(download.values.get('TotalCashValue') ?? '0') - let positionUnrealizedPnL = new Decimal(0) + + // Position-derived account math is only currency-safe when every line is + // in the base currency. Mixed books (HKD stock + USD stock) used to + // blind-sum different units (ANG-101: HKD -4767 + USD +369 reported as + // USD -4398). TWS hands us per-currency ExchangeRate tags — convert per + // position; if a rate is missing, fall back to TWS's own consolidated + // NetLiquidation tag (already FX-correct) rather than summing garbage. + const mixed = download.positions.some((pos) => (pos.currency || baseCurrency) !== baseCurrency) + + let positionUnrealizedPnL: Decimal | null = new Decimal(0) + let positionMarketValue: Decimal | null = new Decimal(0) for (const pos of download.positions) { - positionUnrealizedPnL = positionUnrealizedPnL.plus(pos.unrealizedPnL) + const ccy = pos.currency || baseCurrency + const rate = ccy === baseCurrency ? new Decimal(1) : this.fxRate(download.values, ccy) + if (rate === null) { positionUnrealizedPnL = null; positionMarketValue = null; break } + positionUnrealizedPnL = positionUnrealizedPnL!.plus(new Decimal(pos.unrealizedPnL).mul(rate)) + positionMarketValue = positionMarketValue!.plus(new Decimal(pos.marketValue).mul(rate)) } - const netLiquidation = download.positions.length > 0 - ? aggregateAccountFromPositions(totalCashValue, download.positions).netLiquidation - : new Decimal(download.values.get('NetLiquidation') ?? '0') - - const unrealizedPnL = download.positions.length > 0 + const brokerNetLiq = new Decimal(download.values.get('NetLiquidation') ?? '0') + // Freshness-vs-authority: same-currency books keep the reconstructed + // value (position marks refresh faster than the server-cached tag, see + // docstring above). Mixed books prefer the broker tag (issue #314) — + // reconstruction is rate-converted and only used when the tag is absent. + const reconstructedNetLiq = positionMarketValue !== null + ? totalCashValue.plus(positionMarketValue) + : null + const netLiquidation = download.positions.length === 0 ? brokerNetLiq + : mixed ? (brokerNetLiq.isZero() ? (reconstructedNetLiq ?? brokerNetLiq) : brokerNetLiq) + : aggregateAccountFromPositions(totalCashValue, download.positions).netLiquidation + + const unrealizedPnL = download.positions.length > 0 && positionUnrealizedPnL !== null ? positionUnrealizedPnL : new Decimal(download.values.get('UnrealizedPnL') ?? '0') return { - baseCurrency: download.values.get('BaseCurrency') ?? 'USD', + baseCurrency, netLiquidation: netLiquidation.toString(), totalCashValue: totalCashValue.toString(), unrealizedPnL: unrealizedPnL.toString(), @@ -483,6 +553,7 @@ export class IbkrBroker implements IBroker { * snapshot mode and can see overnight session data. */ async getPositions(): Promise { + this._ensureAlive() const download = this.bridge.getAccountCache() if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') return download.positions diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts index ea17976b4f..9c63bab412 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.spec.ts @@ -99,3 +99,38 @@ describe('RequestBridge — account cache delta semantics', () => { expect(b.getAccountCache()!.positions.map((p) => p.contract.conId)).toEqual([1]) }) }) + +describe('RequestBridge — currency-aware account values (issue #295)', () => { + function readyBridge(): RequestBridge { + const b = new RequestBridge() + ;(b as unknown as { accountCachePending_: unknown }).accountCachePending_ = { positions: [], values: new Map() } + b.accountDownloadEnd('DU1') + return b + } + + it('BASE wins the plain key regardless of arrival order', () => { + const b = readyBridge() + b.updateAccountValue('CashBalance', '1036370', 'BASE', 'DU1') + b.updateAccountValue('CashBalance', '-51005', 'HKD', 'DU1') // arrives after BASE + const v = b.getAccountCache()!.values + expect(v.get('CashBalance')).toBe('1036370') // not clobbered + expect(v.get('CashBalance:HKD')).toBe('-51005') + expect(v.get('CashBalance:BASE')).toBe('1036370') + }) + + it('BASE arriving late still reclaims the plain key', () => { + const b = readyBridge() + b.updateAccountValue('CashBalance', '-51005', 'HKD', 'DU1') // HKD first + const v = b.getAccountCache()!.values + expect(v.get('CashBalance')).toBe('-51005') // provisional + b.updateAccountValue('CashBalance', '1036370', 'BASE', 'DU1') + expect(v.get('CashBalance')).toBe('1036370') // corrected + }) + + it('single-send tags (one currency line, no BASE) keep the plain key', () => { + const b = readyBridge() + b.updateAccountValue('NetLiquidation', '1046101.70', 'USD', 'DU1') + expect(b.getAccountCache()!.values.get('NetLiquidation')).toBe('1046101.70') + expect(b.getAccountCache()!.values.get('ExchangeRate:USD')).toBeUndefined() + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts index 9af0501a16..83cc0fc716 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -374,7 +374,18 @@ export class RequestBridge extends DefaultEWrapper { this.accountId_ = accounts[0] ?? null } + /** True once the socket is known-dead (connectionClosed or a failed + * heartbeat) and until the next successful (re)connect. The IBKR account + * surface is cache-backed — without this flag a dead-but-idle connection + * serves stale data and SWALLOWS ORDERS while health stays green + * (issue #294). */ + private connectionDead_ = false + get connectionDead(): boolean { return this.connectionDead_ } + markDead(): void { this.connectionDead_ = true } + markAlive(): void { this.connectionDead_ = false } + override connectionClosed(): void { + this.connectionDead_ = true this.rejectAll(new BrokerError('NETWORK', 'Connection to TWS/Gateway lost')) if (this.connectReject) { @@ -531,10 +542,20 @@ export class RequestBridge extends DefaultEWrapper { if (this.accountCache_) this.upsertPosition(this.accountCache_.positions, contract, row) } - override updateAccountValue(key: string, val: string, _currency: string, _accountName: string): void { - this.accountCachePending_?.values.set(key, val) + override updateAccountValue(key: string, val: string, currency: string, _accountName: string): void { + // Multi-currency families (CashBalance, NetLiquidationByCurrency, + // ExchangeRate, …) arrive once PER CURRENCY plus a consolidated BASE + // line. Store the composite key always; the plain key is reserved for + // the consolidated value — BASE wins it and, once written, a + // per-currency line can never overwrite it (issue #295: last-write-wins + // left whichever currency arrived last in the plain slot). + const apply = (m: Map): void => { + if (currency) m.set(`${key}:${currency}`, val) + if (!currency || currency === 'BASE' || !m.has(`${key}:BASE`)) m.set(key, val) + } + if (this.accountCachePending_) apply(this.accountCachePending_.values) // Deltas must be visible immediately, not at the next downloadEnd swap. - this.accountCache_?.values.set(key, val) + if (this.accountCache_) apply(this.accountCache_.values) } override accountDownloadEnd(_accountName: string): void { diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index 2e52d3adb7..2839b232a5 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -708,7 +708,7 @@ function TestResultPanel({ result, utaId }: { result: TestConnectionResult; utaI {visiblePositions.map((p, i) => ( - {p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '?'} + {p.contract.symbol ?? p.contract.localSymbol ?? p.contract.aliceId ?? '?'} {p.side} {p.quantity} {p.currency} {p.marketValue} diff --git a/ui/src/pages/UTADetailPage.tsx b/ui/src/pages/UTADetailPage.tsx index 4562d4daa8..113c494d3d 100644 --- a/ui/src/pages/UTADetailPage.tsx +++ b/ui/src/pages/UTADetailPage.tsx @@ -713,8 +713,8 @@ function OpenOrdersTable({ orders }: { orders: unknown[] }) { {rows.map((o, i) => ( {String(o.orderId ?? '—')} - - {o.contract?.aliceId ?? o.contract?.localSymbol ?? o.contract?.symbol ?? '?'} + + {o.contract?.symbol ?? o.contract?.localSymbol ?? o.contract?.aliceId ?? '?'} {o.order?.action ?? '—'} {o.order?.orderType ?? '—'} From 2f77f68820c4bf747ec3ea7a14f410c43d86d70c Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 13 Jun 2026 01:55:36 +0800 Subject: [PATCH 6/6] fix(uta,ibkr): option sign/unit correctness (S14) + multi-row sync boot-loop crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community "option PnL direction is flipped" report, reproduced live via the four-combo matrix (long/short × call/put on AAPL paper) and S14-ified in the catalog: 1. IBKR averageCost is PER CONTRACT (multiplier-baked: 103 for an option bought at 1.03) while marketPrice is per unit. TWS's own pass-through PnL was correct, but every surface that RECOMPUTES (mark − avg) × mult — simulator, any pnlOf consumer — produced ~100x-wrong, sign-inverted numbers: a losing short put showed +10,181. avgCost is normalized to per-unit at the bridge. 2. simulatePriceChange matched positions by bare symbol — option rows sharing the underlying's symbol got RE-MARKED WITH THE STOCK PRICE (observed live: simulatedPrice 275.98 on a 1.15 put, +23,874% "move"). Symbol-level changes now exclude derivative secTypes with a loud note; 'all' scales each row's own mark; the simulated math is multiplier-aware (was also dropping ×100 on contract values). 3. Round-6 regression, found the hard way: getPendingOrderIds read operations[j] for every RESULT row — sync commits store 1 op with N results, so the first multi-update sync commit (two option fills in one poller pass) crashed the UTA process on EVERY boot once persisted in the journal. operations[j] ?? operations[0], plus getOperationSymbol tolerates undefined. This is exactly the workspace-agent-equivalence class: an in-workspace AI placing two orders would have bricked its UTA the same way. Live-verified: short call (+6.52 on 1.0223→0.9571) and short put (−10.99 on 1.0296→1.1395) both sign-correct on every surface; simulator excludes "AAPL OPT 260, AAPL OPT 320" loudly while moving the stock leg −5%; closed positions vanish from the cache immediately (zero-qty removal). Account left flat at baseline. Catalog gains S14. Known gap noted: venue reject REASONS for late rejections (post-submit, caught by sync) don't reach the ledger row — only the status does. Co-Authored-By: Claude Fable 5 --- docs/uta-live-testing.md | 14 ++++ packages/uta-protocol/src/types/git.ts | 3 +- .../domain/trading/brokers/ibkr/IbkrBroker.ts | 5 +- .../trading/brokers/ibkr/request-bridge.ts | 9 ++- .../src/domain/trading/git/TradingGit.spec.ts | 76 +++++++++++++++++++ .../uta/src/domain/trading/git/TradingGit.ts | 57 ++++++++++---- 6 files changed, 145 insertions(+), 19 deletions(-) diff --git a/docs/uta-live-testing.md b/docs/uta-live-testing.md index a9aee1864d..e658378a70 100644 --- a/docs/uta-live-testing.md +++ b/docs/uta-live-testing.md @@ -175,6 +175,20 @@ 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 Rounds 1–5 (2026-06-12, okx + bybit + alpaca demo): ~20 bugs found and diff --git a/packages/uta-protocol/src/types/git.ts b/packages/uta-protocol/src/types/git.ts index 1b63942fd0..93b6f10a9f 100644 --- a/packages/uta-protocol/src/types/git.ts +++ b/packages/uta-protocol/src/types/git.ts @@ -283,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 diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 61a03fcf1a..22313914a9 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -503,7 +503,10 @@ export class IbkrBroker implements IBroker { const rate = ccy === baseCurrency ? new Decimal(1) : this.fxRate(download.values, ccy) if (rate === null) { positionUnrealizedPnL = null; positionMarketValue = null; break } positionUnrealizedPnL = positionUnrealizedPnL!.plus(new Decimal(pos.unrealizedPnL).mul(rate)) - positionMarketValue = positionMarketValue!.plus(new Decimal(pos.marketValue).mul(rate)) + // marketValue is always-positive by convention (side carried apart) — + // shorts must SUBTRACT from equity (see aggregateAccountFromPositions). + const sided = pos.side === 'short' ? new Decimal(pos.marketValue).neg() : new Decimal(pos.marketValue) + positionMarketValue = positionMarketValue!.plus(sided.mul(rate)) } const brokerNetLiq = new Decimal(download.values.get('NetLiquidation') ?? '0') diff --git a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts index 83cc0fc716..a7dc395193 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -522,12 +522,19 @@ export class RequestBridge extends DefaultEWrapper { // Zero quantity = position fully closed — must REMOVE from cache, not // be ignored (a closed position used to linger until the next full // download). + // IBKR's averageCost is PER CONTRACT (multiplier-baked: an option bought + // at 1.03 reports averageCost 103) while marketPrice is per unit. Every + // downstream consumer that recomputes PnL as (mark − avg) × mult would + // produce inverted, ~100x-wrong numbers for derivatives (the community + // "option PnL direction is flipped" report). Normalize to per-unit here. + const multDec = new Decimal(contract.multiplier || '1') + const avgPerUnit = multDec.gt(1) ? new Decimal(averageCost).div(multDec).toString() : averageCost const row = position.isZero() ? null : buildPosition({ contract, currency: contract.currency || 'USD', side: position.greaterThan(0) ? 'long' : 'short', quantity: position.abs(), - avgCost: averageCost, + avgCost: avgPerUnit, marketPrice, // TWS already bakes contract.multiplier into the values it reports // here — pass through as-is (don't re-derive). multiplier is surfaced diff --git a/services/uta/src/domain/trading/git/TradingGit.spec.ts b/services/uta/src/domain/trading/git/TradingGit.spec.ts index d59cec5f7a..e5a7d4b8d1 100644 --- a/services/uta/src/domain/trading/git/TradingGit.spec.ts +++ b/services/uta/src/domain/trading/git/TradingGit.spec.ts @@ -765,6 +765,29 @@ describe('TradingGit', () => { expect(gitP.getPendingOrderIds().map((p) => p.orderId).sort()).toEqual(['leg-sl', 'parent-1']) }) + it('survives a multi-update sync commit (1 op, N results — boot-loop regression)', async () => { + const gitP = new TradingGit(makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ success: true, orderId: 'o-1' }), + })) + gitP.add(buyOp('AAPL')) + gitP.commit('buy') + await gitP.push() + + // One sync commit carrying TWO updates → operations[1] is undefined; + // the pending scan crashed the whole UTA process on every boot once + // such a commit was persisted in the journal. + await gitP.sync( + [ + { orderId: 'o-1', symbol: 'AAPL', previousStatus: 'submitted', currentStatus: 'filled', filledPrice: '10', filledQty: '1' }, + { orderId: 'o-2', symbol: 'MSFT', previousStatus: 'submitted', currentStatus: 'cancelled' }, + ], + makeGitState(), + ) + + expect(() => gitP.getPendingOrderIds()).not.toThrow() + expect(gitP.getPendingOrderIds()).toHaveLength(0) + }) + it('excludes orders that were filled at push time (no sync needed)', async () => { const orderState = new OrderState() orderState.status = 'Filled' @@ -812,6 +835,59 @@ describe('TradingGit', () => { // ==================== simulatePriceChange ==================== + describe('simulatePriceChange — derivative handling (community sign-flip report)', () => { + it('excludes option rows from a symbol-level change and applies multiplier to applied rows', async () => { + const optContract = makeContract({ symbol: 'AAPL' }) + optContract.secType = 'OPT' + optContract.strike = 260 + optContract.right = 'P' + const gitS = new TradingGit(makeConfig({ + getGitState: vi.fn().mockResolvedValue(makeGitState({ + positions: [ + { contract: makeContract({ symbol: 'AAPL' }), currency: 'USD', side: 'long', + quantity: new Decimal(10), avgCost: '261', marketPrice: '290', + marketValue: '2900', unrealizedPnL: '290', realizedPnL: '0', multiplier: '1' }, + // short put — its own price must NOT be replaced by the stock's + { contract: optContract, currency: 'USD', side: 'short', + quantity: new Decimal(1), avgCost: '1.03', marketPrice: '1.15', + marketValue: '115', unrealizedPnL: '-12', realizedPnL: '0', multiplier: '100' }, + ] as never, + })), + })) + + const r = await gitS.simulatePriceChange([{ symbol: 'AAPL', change: '-5%' }]) + expect(r.success).toBe(true) + const rows = r.simulatedState.positions + // Stock row moved; option row untouched (no +23,000% garbage) + expect(Number(rows[0].simulatedPrice)).toBeCloseTo(275.5, 1) + expect(rows[1].simulatedPrice).toBe('1.15') + expect(rows[1].pnlChange).toBe('0') + // exclusion is loud, not silent + expect(r.summary.worstCase).toMatch(/derivative positions not simulated/i) + expect(r.summary.worstCase).toMatch(/OPT/) + }) + + it("'all' scales a derivative's OWN mark with multiplier-aware math", async () => { + const optContract = makeContract({ symbol: 'AAPL' }) + optContract.secType = 'OPT' + const gitS = new TradingGit(makeConfig({ + getGitState: vi.fn().mockResolvedValue(makeGitState({ + positions: [ + { contract: optContract, currency: 'USD', side: 'short', + quantity: new Decimal(1), avgCost: '1.03', marketPrice: '1.00', + marketValue: '100', unrealizedPnL: '3', realizedPnL: '0', multiplier: '100' }, + ] as never, + })), + })) + const r = await gitS.simulatePriceChange([{ symbol: 'all', change: '+10%' }]) + const row = r.simulatedState.positions[0] + // own mark 1.00 → 1.10; short: (1.03 − 1.10) × 1 × 100 = −7 + expect(Number(row.simulatedPrice)).toBeCloseTo(1.10, 8) + expect(Number(row.unrealizedPnL)).toBeCloseTo(-7, 6) + expect(Number(row.marketValue)).toBeCloseTo(110, 6) + }) + }) + describe('simulatePriceChange', () => { it('returns empty state when no positions', async () => { const result = await git.simulatePriceChange([{ symbol: 'AAPL', change: '-10%' }]) diff --git a/services/uta/src/domain/trading/git/TradingGit.ts b/services/uta/src/domain/trading/git/TradingGit.ts index 870bba0645..52832e7cd8 100644 --- a/services/uta/src/domain/trading/git/TradingGit.ts +++ b/services/uta/src/domain/trading/git/TradingGit.ts @@ -6,7 +6,7 @@ import { createHash } from 'crypto' import Decimal from 'decimal.js' -import { Contract, Order, UNSET_DECIMAL } from '@traderalice/ibkr' +import { Contract, Order, UNSET_DECIMAL, UNSET_DOUBLE } from '@traderalice/ibkr' import { OrderHelper } from '../OrderHelper.js' import type { ITradingGit, TradingGitConfig } from './interfaces.js' import type { @@ -31,6 +31,10 @@ import type { } from './types.js' import { getOperationSymbol } from './types.js' +/** secTypes whose price does NOT track the underlying 1:1 — excluded from + * symbol-level price simulation (they share the underlying's symbol). */ +const DERIVATIVE_SECTYPES = new Set(['OPT', 'FOP', 'WAR', 'IOPT', 'BAG']) + function generateCommitHash(content: object): CommitHash { const hash = createHash('sha256') .update(JSON.stringify(content)) @@ -620,7 +624,10 @@ export class TradingGit implements ITradingGit { for (const commit of this.commits) { for (let j = 0; j < commit.results.length; j++) { const result = commit.results[j] - const op = commit.operations[j] + // Sync commits store ONE syncOrders op with N per-order results — + // operations[j] is undefined past index 0 (a multi-update sync + // commit in the journal turned this into a BOOT-LOOP crash once). + const op = commit.operations[j] ?? commit.operations[0] const symbol = getOperationSymbol(op) // Broker-native symbol for symbol-scoped order lookups (CCXT). // Persisted with the operation, so it survives process restarts @@ -678,8 +685,10 @@ export class TradingGit implements ITradingGit { } } - // Parse price changes → target price map - const priceMap = new Map() + // Parse price changes → per-position target prices. Index-keyed: bare + // symbols collide between an underlying and its derivatives. + const priceByIndex = new Map() + const excludedDerivatives: string[] = [] for (const { symbol, change } of priceChanges) { const parsed = this.parsePriceChange(change) @@ -694,13 +703,24 @@ export class TradingGit implements ITradingGit { } if (symbol === 'all') { - for (const pos of positions) { - priceMap.set(pos.contract.symbol || 'unknown', this.applyPriceChange(new Decimal(pos.marketPrice), parsed.type, parsed.value)) + for (let i = 0; i < positions.length; i++) { + // 'all' scales each position's OWN mark — valid for derivatives too. + priceByIndex.set(i, this.applyPriceChange(new Decimal(positions[i].marketPrice), parsed.type, parsed.value)) } } else { - const pos = positions.find((p) => (p.contract.symbol || p.contract.aliceId) === symbol) - if (pos) { - priceMap.set(symbol, this.applyPriceChange(new Decimal(pos.marketPrice), parsed.type, parsed.value)) + for (let i = 0; i < positions.length; i++) { + const pos = positions[i] + if ((pos.contract.symbol || pos.contract.aliceId) !== symbol) continue + // A symbol-level price change describes the UNDERLYING. Derivative + // rows share the symbol but do NOT move 1:1 with it (an option's + // own price is not the stock's price) — re-marking them with the + // stock price produced +23,000% "moves" and inverted PnL. Exclude + // loudly instead of pricing garbage. + if (DERIVATIVE_SECTYPES.has(pos.contract.secType)) { + excludedDerivatives.push(`${symbol} ${pos.contract.secType}${pos.contract.strike && !new Decimal(pos.contract.strike).equals(UNSET_DOUBLE) ? ' ' + pos.contract.strike : ''}`) + continue + } + priceByIndex.set(i, this.applyPriceChange(new Decimal(pos.marketPrice), parsed.type, parsed.value)) } } } @@ -718,19 +738,21 @@ export class TradingGit implements ITradingGit { // Simulated state let simulatedUnrealizedPnL = new Decimal(0) - const simulatedPositions = positions.map((pos) => { + const simulatedPositions = positions.map((pos, i) => { const sym = pos.contract.symbol || pos.contract.aliceId || 'unknown' const mktPrice = new Decimal(pos.marketPrice) - const simulatedPrice = priceMap.get(sym) ?? mktPrice + const simulatedPrice = priceByIndex.get(i) ?? mktPrice const priceChange = simulatedPrice.minus(mktPrice) const priceChangePct = mktPrice.gt(0) ? priceChange.div(mktPrice).mul(100) : new Decimal(0) const q = pos.quantity const avgCost = new Decimal(pos.avgCost) + // Multiplier-aware: 1 option contract at price 1.15 is $115 of value. + const mult = new Decimal(pos.multiplier || '1') const newPnL = pos.side === 'long' - ? simulatedPrice.minus(avgCost).mul(q) - : avgCost.minus(simulatedPrice).mul(q) + ? simulatedPrice.minus(avgCost).mul(q).mul(mult) + : avgCost.minus(simulatedPrice).mul(q).mul(mult) const pnlChange = newPnL.minus(pos.unrealizedPnL) simulatedUnrealizedPnL = simulatedUnrealizedPnL.plus(newPnL) @@ -742,7 +764,7 @@ export class TradingGit implements ITradingGit { avgCost: pos.avgCost, simulatedPrice: simulatedPrice.toString(), unrealizedPnL: newPnL.toString(), - marketValue: simulatedPrice.mul(q).toString(), + marketValue: simulatedPrice.mul(q).mul(mult).toString(), pnlChange: pnlChange.toString(), priceChangePercent: `${priceChangePct.gte(0) ? '+' : ''}${priceChangePct.toFixed(2)}%`, } @@ -758,10 +780,13 @@ export class TradingGit implements ITradingGit { { ...simulatedPositions[0], pnlChange: new Decimal(simulatedPositions[0].pnlChange) }, ) + const excludedNote = excludedDerivatives.length > 0 + ? ` NOTE: derivative positions not simulated (their price does not track the underlying 1:1): ${excludedDerivatives.join(', ')}.` + : '' const worstCase = - worst.pnlChange.lt(0) + (worst.pnlChange.lt(0) ? `${worst.symbol} would lose $${worst.pnlChange.abs().toFixed(2)} (${worst.priceChangePercent})` - : 'All positions would profit or break even.' + : 'All positions would profit or break even.') + excludedNote return { success: true,