From ae894a1b61295c5afd2e3c5546cb1c5704a79d86 Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 19:59:38 -0700 Subject: [PATCH 1/9] feat(ibkr): place attached TP/SL as a native TWS bracket IBKR placeOrder with takeProfit/stopLoss now submits parent + children (transmit chain, parentId, ocaType=1) and returns PlaceOrderResult.legs instead of refusing a naked entry. Children sit in the OCA group; the parent does not. Far targets set overridePercentageConstraints; child TIF is GTC so protection survives a GTD/DAY fill. Standalone --oca-group defaults ocaType=1. The bracket always mints its own uta-br- group, and placeOrder refuses a caller-supplied ocaGroup outside its try/catch so the combination surfaces as a CONFIG error. Moving a caller group onto the protective children and stripping it from the entry made a take-profit fill cancel every unrelated resting order in that group while the entry lost its intended membership. `placeBracketOrder` registers the parent and child order requests before its try block, and `Promise.allSettled` inside it is their only observer. A synchronous throw from `client.placeOrder` jumps to the catch, leaving those requests to reject unobserved at their own 10s timeout -- an unhandled rejection, which ends the UTA process under Node's default. Every leg request is now observed at registration. Hardening found while testing the bracket on a live gateway: - Refuse an attached TP/SL on a monetary-value (cashQty) entry instead of forwarding the notional to the protective children; TWS rejects a notional STP leg with 10244 and the position would be partly uncovered. - Only unwind the bracket chain when the parent has NOT filled. Cancelling the surviving legs behind a filled entry stripped protection off a real position; that case now returns the entry plus the acknowledged legs with a message naming the gap. - Gate cancel completion on a cancel-confirming status via an `accepts` predicate on requestOrder, so a fill that beat the cancel cannot resolve the cancel request as Cancelled over an open position. - Park an Inactive openOrder/orderStatus for a grace window instead of dropping it. TWS uses Inactive for exchange-closed and precautionary holds with no error(), which turned a live order into a NETWORK timeout. - Recognise Decimal order fields across realms, send an empty totalQuantity for monetary-value orders, and strip IBKR UNSET sentinels from inbound records and numeric Order fields. OrderHelper.scrub passes Date/Map/Set/RegExp/Error/binary through instead of flattening them. --- docs/uta-live-testing.md | 10 +- packages/ibkr/src/client/orders.ts | 5 +- packages/ibkr/src/comm.ts | 13 +- packages/ibkr/tests/comm.spec.ts | 43 +++- .../ibkr/tests/order-monetary-value.spec.ts | 90 +++++++ .../uta/src/domain/trading/OrderHelper.ts | 75 ++++-- .../trading/brokers/ibkr/IbkrBroker.spec.ts | 185 +++++++++++++-- .../domain/trading/brokers/ibkr/IbkrBroker.ts | 114 +++++++-- .../src/domain/trading/brokers/ibkr/README.md | 50 ++++ .../trading/brokers/ibkr/ibkr-bracket.spec.ts | 219 ++++++++++++++++++ .../trading/brokers/ibkr/ibkr-bracket.ts | 136 +++++++++++ .../brokers/ibkr/request-bridge.spec.ts | 98 +++++++- .../trading/brokers/ibkr/request-bridge.ts | 99 ++++++-- .../src/domain/trading/git/TradingGit.spec.ts | 37 +++ .../uta/src/domain/trading/git/TradingGit.ts | 18 +- 15 files changed, 1101 insertions(+), 91 deletions(-) create mode 100644 packages/ibkr/tests/order-monetary-value.spec.ts create mode 100644 services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.spec.ts create mode 100644 services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts diff --git a/docs/uta-live-testing.md b/docs/uta-live-testing.md index 6bfbebbd5a..2f0853709f 100644 --- a/docs/uta-live-testing.md +++ b/docs/uta-live-testing.md @@ -250,12 +250,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. On a native-bracket venue (Alpaca): the push result must carry +working. On a native-bracket venue (Alpaca, IBKR): 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 +tracked orders. Alpaca's held SL leg never appears in the venue's open-orders +listing (it stays `held` 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, +a missed leg. IBKR children do appear (an untriggered stop is +`PreSubmitted`) and share an OCA group with `ocaType=1`; the parent is not +in that group. *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".* diff --git a/packages/ibkr/src/client/orders.ts b/packages/ibkr/src/client/orders.ts index 16c889f6e0..1ff73a7581 100644 --- a/packages/ibkr/src/client/orders.ts +++ b/packages/ibkr/src/client/orders.ts @@ -349,7 +349,10 @@ export function applyOrders(Client: typeof EClient): void { flds.push(makeField(order.action)) if (this.serverVersion() >= SV.MIN_SERVER_VER_FRACTIONAL_POSITIONS) { - flds.push(makeField(order.totalQuantity)) + // A monetary-value order leaves `totalQuantity` unset, and a raw + // sentinel goes out as a 39-digit share count that TWS rejects with + // `320 Error reading request. Unable to parse field`. + flds.push(makeFieldHandleEmpty(order.totalQuantity)) } else { flds.push(makeField(order.totalQuantity.toNumber() | 0)) } diff --git a/packages/ibkr/src/comm.ts b/packages/ibkr/src/comm.ts index 37247522be..0f78be1f18 100644 --- a/packages/ibkr/src/comm.ts +++ b/packages/ibkr/src/comm.ts @@ -8,6 +8,15 @@ import { UNSET_INTEGER, UNSET_DOUBLE, UNSET_DECIMAL, DOUBLE_INFINITY, INFINITY_S import { ClientException, isAsciiPrintable } from './utils.js' import { INVALID_SYMBOL } from './errors.js' +/** + * Realm-safe `Decimal` detection. A broker pack can load a second `decimal.js` + * copy, and `instanceof` would then miss the UNSET sentinel and transmit + * 2^127-1 as a real value. + */ +function isDecimal(val: unknown): val is Decimal { + return Decimal.isDecimal(val) +} + /** * Wrap protobuf data with 4-byte big-endian length prefix and msgId. * Wire format: [4-byte total length][4-byte msgId BE][protobuf bytes] @@ -60,7 +69,7 @@ export function makeField(val: unknown): string { // Decimal: use toFixed() to avoid scientific notation on small values // (Decimal.toString() uses '1e-8' by default; TWS wire expects '0.00000001'). - if (val instanceof Decimal) { + if (isDecimal(val)) { return val.toFixed() + '\0' } @@ -89,7 +98,7 @@ export function makeFieldHandleEmpty(val: unknown): string { throw new Error('Cannot send None to TWS') } - if (val instanceof Decimal) { + if (isDecimal(val)) { if (val.equals(UNSET_DECIMAL)) return makeField('') return makeField(val) } diff --git a/packages/ibkr/tests/comm.spec.ts b/packages/ibkr/tests/comm.spec.ts index 9f980ea7c9..ea4511183c 100644 --- a/packages/ibkr/tests/comm.spec.ts +++ b/packages/ibkr/tests/comm.spec.ts @@ -3,9 +3,28 @@ * Tests low-level message framing — encode/decode round-trips. */ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' import { makeInitialMsg, makeField, makeFieldHandleEmpty, makeMsg, makeMsgProto, readMsg, readFields } from '../src/comm.js' -import { UNSET_DOUBLE, UNSET_INTEGER, DOUBLE_INFINITY } from '../src/const.js' +import { UNSET_DOUBLE, UNSET_INTEGER, UNSET_DECIMAL, DOUBLE_INFINITY } from '../src/const.js' + +/** + * A second, independent copy of decimal.js, as a broker pack release installs + * beside the bundled one. Its instances fail `instanceof Decimal` but are still + * real Decimals. + */ +function loadForeignDecimalRealm(): typeof Decimal { + const path = createRequire(import.meta.url).resolve('decimal.js') + const sandbox: Record = { module: { exports: {} } } + sandbox['exports'] = (sandbox['module'] as { exports: unknown }).exports + sandbox['globalThis'] = sandbox + runInNewContext(readFileSync(path, 'utf8'), sandbox, { filename: path }) + const exported = (sandbox['module'] as { exports: Record }).exports + return (exported['default'] ?? exported) as typeof Decimal +} describe('comm', () => { @@ -81,6 +100,28 @@ describe('comm', () => { expect(makeFieldHandleEmpty(DOUBLE_INFINITY)).toBe('Infinity\0') }) + describe('Decimal fields from a foreign realm', () => { + const ForeignDecimal = loadForeignDecimalRealm() + + it('the fixture really is a foreign realm', () => { + const value = new ForeignDecimal('1') + expect(value instanceof Decimal).toBe(false) + expect(Decimal.isDecimal(value)).toBe(true) + }) + + it('makeFieldHandleEmpty: UNSET sentinel still means "field absent"', () => { + expect(makeFieldHandleEmpty(new ForeignDecimal(UNSET_DECIMAL.toFixed()))).toBe('\0') + }) + + it('makeFieldHandleEmpty: a set value is still transmitted', () => { + expect(makeFieldHandleEmpty(new ForeignDecimal('5000'))).toBe('5000\0') + }) + + it('makeField: small values keep fixed notation', () => { + expect(makeField(new ForeignDecimal('0.00000001'))).toBe('0.00000001\0') + }) + }) + it('readMsg: incomplete message returns empty payload', () => { // Only 2 bytes — not even a size prefix const buf = Buffer.from([0x00, 0x01]) diff --git a/packages/ibkr/tests/order-monetary-value.spec.ts b/packages/ibkr/tests/order-monetary-value.spec.ts new file mode 100644 index 0000000000..ed5c3c9170 --- /dev/null +++ b/packages/ibkr/tests/order-monetary-value.spec.ts @@ -0,0 +1,90 @@ +/** + * A notional order sets `cashQty` and leaves `totalQuantity` unset, so no + * sentinel digits may reach the encoded payload. + */ + +import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' +import { EClient } from '../src/client/index.js' +import { Contract } from '../src/contract.js' +import { Order } from '../src/order.js' +import { UNSET_DECIMAL } from '../src/const.js' +import { MAX_CLIENT_VER } from '../src/server-versions.js' +import type { EWrapper } from '../src/wrapper.js' + +/** Sentinel as it would reach the wire, in either notation. */ +const SENTINEL_DIGITS = '17014118346046923' + +class CaptureClient extends EClient { + readonly sent: string[] = [] + override isConnected(): boolean { return true } + override serverVersion(): number { return MAX_CLIENT_VER } + override sendMsg(_msgId: number, msg: string): void { this.sent.push(msg) } +} + +function newClient(): CaptureClient { + const errors: unknown[] = [] + const wrapper = { + error: (...args: unknown[]) => { errors.push(args) }, + } as unknown as EWrapper + return new CaptureClient(wrapper) +} + +function stockContract(): Contract { + const contract = new Contract() + contract.symbol = 'IBIT' + contract.secType = 'STK' + contract.exchange = 'SMART' + contract.currency = 'USD' + return contract +} + +describe('placeOrder — monetary-value order', () => { + it('sends an empty totalQuantity when only cashQty is set', () => { + const client = newClient() + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.tif = 'DAY' + order.cashQty = new Decimal('5000') + expect(order.totalQuantity.equals(UNSET_DECIMAL)).toBe(true) + + client.placeOrder(1, stockContract(), order) + + expect(client.sent).toHaveLength(1) + const payload = client.sent[0]! + // BUY is followed by the totalQuantity field, which must be empty. + expect(payload).toContain('BUY\0\0') + expect(payload).toContain('5000\0') + expect(payload).not.toContain(SENTINEL_DIGITS) + }) + + it('still sends a share quantity when totalQuantity is set', () => { + const client = newClient() + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.tif = 'DAY' + order.totalQuantity = new Decimal('15') + + client.placeOrder(1, stockContract(), order) + + const payload = client.sent[0]! + expect(payload).toContain('BUY\u000015\u0000') + expect(payload).not.toContain(SENTINEL_DIGITS) + }) + + it('keeps fractional share quantities exact', () => { + const client = newClient() + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal('0.00000001') + + client.placeOrder(1, stockContract(), order) + + const payload = client.sent[0]! + expect(payload).toContain('0.00000001\0') + expect(payload).not.toContain('1e-8') + }) +}) diff --git a/services/uta/src/domain/trading/OrderHelper.ts b/services/uta/src/domain/trading/OrderHelper.ts index 685e1050e5..d84087d73d 100644 --- a/services/uta/src/domain/trading/OrderHelper.ts +++ b/services/uta/src/domain/trading/OrderHelper.ts @@ -30,18 +30,7 @@ import Decimal from 'decimal.js' import type { Order } from '@traderalice/ibkr' -import { UNSET_DECIMAL } from '@traderalice/ibkr' - -// Add new Decimal-valued optional Order fields here too. -const SENTINEL_DECIMAL_FIELDS = [ - 'totalQuantity', - 'cashQty', - 'lmtPrice', - 'auxPrice', - 'trailStopPrice', - 'trailingPercent', - 'filledQuantity', -] as const +import { UNSET_DECIMAL, UNSET_DOUBLE, UNSET_INTEGER } from '@traderalice/ibkr' /** * Narrow nullable view for broker-internal consumption. Only the fields @@ -69,6 +58,36 @@ function unsetToUndef(d: Decimal): Decimal | undefined { return d.equals(UNSET_DECIMAL) ? undefined : d } +/** Sentinel text as the decoder stringifies it, in either Decimal notation. */ +const SENTINEL_TEXT = new Set([ + UNSET_DECIMAL.toString(), + UNSET_DECIMAL.toFixed(), + String(UNSET_DOUBLE), +]) + +/** + * Objects whose state does not live in enumerable own properties. Walking + * them with `Object.entries` yields `{}` and loses the value entirely. + */ +function isOpaqueObject(value: object): boolean { + return ( + value instanceof Date + || value instanceof Map + || value instanceof Set + || value instanceof RegExp + || value instanceof Error + || ArrayBuffer.isView(value) + || value instanceof ArrayBuffer + ) +} + +function isSentinelValue(value: unknown): boolean { + if (typeof value === 'number') return value === UNSET_DOUBLE || value === UNSET_INTEGER + if (typeof value === 'string') return SENTINEL_TEXT.has(value) + if (Decimal.isDecimal(value)) return (value as Decimal).equals(UNSET_DECIMAL) + return false +} + export const OrderHelper = { /** Typed nullable view; sentinel → undefined. */ read(order: Order): OrderView { @@ -92,13 +111,31 @@ export const OrderHelper = { // Accepts Order or Partial (modifyOrder.changes is the latter). toWire(order: Order | Partial): Record { - const out: Record = { ...order } - for (const field of SENTINEL_DECIMAL_FIELDS) { - const v = out[field] - if (Decimal.isDecimal(v) && v.equals(UNSET_DECIMAL)) { - delete out[field] - } + // Matching by value also covers the ~40 numeric fields Order class-defaults + // to UNSET_DOUBLE / UNSET_INTEGER (percentOffset, delta, minQty, scale*). + return OrderHelper.scrub({ ...order } as Record) + }, + + /** + * Deep-remove IBKR sentinel values so they never cross the JSON boundary. + * Dropping a key is safe for resend: `TradingGit.rehydrateOrder` rebuilds from + * `new Order()`, whose fields already default to the sentinel. + */ + scrub(value: T): T { + if (Array.isArray(value)) { + return value.map((entry) => OrderHelper.scrub(entry)) as unknown as T + } + if (Decimal.isDecimal(value) || value === null || typeof value !== 'object') { + return value + } + // Object.entries() would flatten a Date/Map/Set/Buffer to `{}` and destroy + // the value. + if (isOpaqueObject(value)) return value + const out: Record = {} + for (const [key, entry] of Object.entries(value as Record)) { + if (isSentinelValue(entry)) continue + out[key] = OrderHelper.scrub(entry) } - return out + return out as unknown as T }, } 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 48727db763..19ac58ba6e 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import Decimal from 'decimal.js' -import { Contract, Order } from '@traderalice/ibkr' +import { Contract, Order, UNSET_DECIMAL } from '@traderalice/ibkr' import { IbkrBroker } from './IbkrBroker.js' import contractCorpus from './__fixtures__/contract-resolution.v1.json' @@ -45,6 +45,7 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { requestCollector: ReturnType requestSnapshot: ReturnType requestCurrentTime: ReturnType + getNextOrderId: ReturnType requestOrder: ReturnType markDead: ReturnType } @@ -56,14 +57,18 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { } } { const broker = new IbkrBroker({ id: 'ibkr-test', host: '127.0.0.1', port: 7497, clientId: 91 }) + let nextOrderId = 42 const bridge = { connectionDead: false, allocReqId: vi.fn(() => 17), requestCollector: vi.fn(async () => [{ contract: Object.assign(new Contract(), resolvedContract) }]), requestSnapshot: vi.fn(async () => ({ last: 0.8, bid: 0.79, ask: 0.81, volume: 1 })), requestCurrentTime: vi.fn(async () => 1_784_289_600), - getNextOrderId: vi.fn(() => 42), - requestOrder: vi.fn(async () => ({ orderState: { status: 'Submitted' } })), + getNextOrderId: vi.fn(() => nextOrderId++), + // Mirrors the bridge: only a cancel-confirming status satisfies `accepts`. + requestOrder: vi.fn(async (_orderId: number, _timeoutMs?: number, accepts?: (status: string) => boolean) => ({ + orderState: { status: accepts && !accepts('Submitted') ? 'Cancelled' : 'Submitted' }, + })), markDead: vi.fn(), } const client = { @@ -259,32 +264,172 @@ describe('IbkrBroker — canonical conId contract resolution', () => { }) }) -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 () => { +describe('IbkrBroker — native attached TP/SL bracket', () => { + it('submits parent + TP + SL and returns child ids as legs', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + const { contract, order } = stkOrder() + + const result = await broker.placeOrder(contract, order, { + takeProfit: { price: '120' }, + stopLoss: { price: '90' }, + }) + + expect(result.success).toBe(true) + expect(result.orderId).toBe('42') + expect(result.legs).toEqual([ + { orderId: '43', kind: 'takeProfit' }, + { orderId: '44', kind: 'stopLoss' }, + ]) + expect(client.placeOrder).toHaveBeenCalledTimes(3) + expect(bridge.requestOrder.mock.calls.map(call => call[0])).toEqual([42, 43, 44]) + expect(bridge.requestCurrentTime).toHaveBeenCalledOnce() + + const [parentId, , parent] = client.placeOrder.mock.calls[0] as [number, Contract, Order] + const [, , tp] = client.placeOrder.mock.calls[1] as [number, Contract, Order] + const [, , sl] = client.placeOrder.mock.calls[2] as [number, Contract, Order] + expect(parentId).toBe(42) + expect(parent.transmit).toBe(false) + expect(parent.ocaGroup).toBe('') + expect(tp.transmit).toBe(false) + expect(tp.parentId).toBe(42) + expect(tp.orderType).toBe('LMT') + expect(sl.transmit).toBe(true) + expect(sl.parentId).toBe(42) + expect(sl.orderType).toBe('STP') + expect(tp.ocaGroup).toBe(sl.ocaGroup) + expect(tp.ocaType).toBe(1) + expect(order.transmit).toBe(true) + }) + + it('submits a one-child OTO when only stopLoss is attached', async () => { + const { broker, client } = brokerWithContractIo(recordedContract('aapl-stock')) const { contract, order } = stkOrder() - const result = await bareBroker().placeOrder(contract, order, { takeProfit: { price: '120' } }) + + const result = await broker.placeOrder(contract, order, { + stopLoss: { price: '90', limitPrice: '89' }, + }) + + expect(result.success).toBe(true) + expect(result.legs).toEqual([{ orderId: '43', kind: 'stopLoss' }]) + expect(client.placeOrder).toHaveBeenCalledTimes(2) + const child = client.placeOrder.mock.calls[1][2] as Order + expect(child.orderType).toBe('STP LMT') + expect(child.transmit).toBe(true) + }) + + it('cancels the chain when a child acknowledgement fails', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestOrder.mockImplementation(async (orderId: number) => { + if (orderId === 44) throw new Error('TWS error 110: The price does not conform to the minimum price variation') + return { orderState: { status: 'Submitted' } } + }) + const { contract, order } = stkOrder() + + const result = await broker.placeOrder(contract, order, { + takeProfit: { price: '120' }, + stopLoss: { price: '90' }, + }) + expect(result.success).toBe(false) - expect(result.error).toMatch(/TP\/SL.*not implemented|refusing/i) + expect(result.error).toMatch(/minimum price variation/) + expect(client.cancelOrder).toHaveBeenCalledTimes(3) }) - it('refuses placeOrder with stopLoss', async () => { + it('keeps the surviving legs when a child fails after the entry filled', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestOrder.mockImplementation(async (orderId: number) => { + if (orderId === 44) throw new Error('TWS error 110: minimum price variation') + if (orderId === 42) return { orderState: { status: 'Filled' } } + return { orderState: { status: 'PreSubmitted' } } + }) const { contract, order } = stkOrder() - const result = await bareBroker().placeOrder(contract, order, { stopLoss: { price: '90' } }) + + const result = await broker.placeOrder(contract, order, { + takeProfit: { price: '120' }, + stopLoss: { price: '90' }, + }) + + expect(result.success).toBe(true) + expect(result.orderId).toBe('42') + expect(result.legs).toEqual([{ orderId: '43', kind: 'takeProfit' }]) + expect(result.message).toMatch(/protective leg/i) + expect(client.cancelOrder).not.toHaveBeenCalled() + }) + + it('refuses a monetary-value entry rather than mis-sizing the protective legs', async () => { + const { broker, client } = brokerWithContractIo(recordedContract('aapl-stock')) + const { contract, order } = stkOrder() + order.totalQuantity = UNSET_DECIMAL + order.cashQty = new Decimal('5000') + + const result = await broker.placeOrder(contract, order, { stopLoss: { price: '90' } }) + expect(result.success).toBe(false) - expect(result.error).toMatch(/refusing/i) + expect(result.error).toMatch(/monetary-value|totalQuantity/i) + expect(client.placeOrder).not.toHaveBeenCalled() + }) + + it('rejects a caller ocaGroup on a bracket entry instead of placing the legs', async () => { + const { broker, client } = brokerWithContractIo(recordedContract('aapl-stock')) + const { contract, order } = stkOrder() + order.ocaGroup = 'swing-mu' + + await expect(broker.placeOrder(contract, order, { takeProfit: { price: '120' } })) + .rejects.toThrow(/mints its own OCA group/) + expect(client.placeOrder).not.toHaveBeenCalled() + }) + + it('defaults ocaType=1 on a standalone order that already has an ocaGroup', async () => { + const { broker, client } = brokerWithContractIo(recordedContract('aapl-stock')) + const { contract, order } = stkOrder() + order.ocaGroup = 'swing-mu' + order.action = 'SELL' + order.orderType = 'STP' + order.auxPrice = new Decimal('90') + + const result = await broker.placeOrder(contract, order) + expect(result.success).toBe(true) + expect(client.placeOrder).toHaveBeenCalledOnce() + const sent = client.placeOrder.mock.calls[0][2] as Order + expect(sent.ocaGroup).toBe('swing-mu') + expect(sent.ocaType).toBe(1) + expect(order.ocaType).toBe(0) }) - it('an empty tpsl object does not trip the gate', async () => { + it('observes every leg request when the parent write throws synchronously', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + // A plain function, not `vi.fn`: a spy attaches its own settled-result + // handler and would observe the rejection on the code's behalf. + ;(bridge as unknown as { requestOrder: unknown }).requestOrder = () => new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('order request timed out')), 0) + }) + client.placeOrder.mockImplementation(() => { throw new Error('TWS socket write failed') }) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + const { contract, order } = stkOrder() + + const result = await broker.placeOrder(contract, order, { + takeProfit: { price: '120' }, + stopLoss: { price: '90' }, + }) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/socket write failed/) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(unhandled).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandled) + } + }) + + it('an empty tpsl object still places a simple entry', async () => { + const { broker, client } = brokerWithContractIo(recordedContract('aapl-stock')) 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/) + const result = await broker.placeOrder(contract, order, {}) + expect(result.success).toBe(true) + expect(result.legs).toBeUndefined() + expect(client.placeOrder).toHaveBeenCalledOnce() }) }) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 3fefa6a44e..41ea5bb198 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -42,8 +42,9 @@ import { } from '../types.js' import '../../contract-ext.js' import { derivePositionMath } from '../../position-math.js' -import { RequestBridge } from './request-bridge.js' +import { RequestBridge, acceptsCancelStatus } from './request-bridge.js' import { resolveSymbol } from './ibkr-contracts.js' +import { applyStandaloneOcaType, buildIbkrBracket, refuseBracketOcaGroup } from './ibkr-bracket.js' import type { IbkrBrokerConfig } from './ibkr-types.js' const WRITE_LIVENESS_TIMEOUT_MS = 3_000 @@ -51,6 +52,10 @@ const OPTION_MARK_SUCCESS_TTL_MS = 15_000 const OPTION_MARK_FAILURE_TTL_MS = 60_000 const OPTION_MARK_CONCURRENCY = 8 +const FILLED_PARENT_STATUSES = new Set(['Filled', 'PartiallyFilled']) + +const CANCEL_CONFIRMED_STATUSES = new Set(['Cancelled', 'ApiCancelled', 'PendingCancel']) + interface OptionMarkOverlay { marketPrice: string marketValue: string @@ -478,23 +483,20 @@ export class IbkrBroker implements IBroker { // ==================== Trading operations ==================== 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.', - } - } + // Refused outside the try/catch so it surfaces as a CONFIG error rather + // than an ordinary { success: false } venue rejection. + refuseBracketOcaGroup(order, tpsl) try { this._ensureAlive() const routedContract = await this.resolveRoutableContract(contract) await this._ensureWriteAlive() + if (tpsl?.takeProfit || tpsl?.stopLoss) { + return await this.placeBracketOrder(routedContract, order, tpsl) + } + const toSend = applyStandaloneOcaType(order) const orderId = this.bridge.getNextOrderId() const promise = this.bridge.requestOrder(orderId) - this.client.placeOrder(orderId, routedContract, order) + this.client.placeOrder(orderId, routedContract, toSend) const result = await promise return { success: true, @@ -506,6 +508,77 @@ export class IbkrBroker implements IBroker { } } + /** + * Registers every orderId before the first write so a fast openOrder callback + * cannot land un-awaited. + */ + private async placeBracketOrder( + contract: Contract, + order: Order, + tpsl: TpSlParams, + ): Promise { + const bracket = buildIbkrBracket(order, tpsl, () => this.bridge.getNextOrderId()) + const ids = [bracket.parentId, ...bracket.children.map(child => child.orderId)] + const pending = [ + this.bridge.requestOrder(bracket.parentId), + ...bracket.children.map(child => this.bridge.requestOrder(child.orderId)), + ] + // A synchronous `placeOrder` throw skips the `allSettled` below, leaving + // these to reject unobserved at their own timeout. + for (const request of pending) request.catch(() => {}) + try { + this.client.placeOrder(bracket.parentId, contract, bracket.parent) + for (const child of bracket.children) { + this.client.placeOrder(child.orderId, contract, child.order) + } + const settled = await Promise.allSettled(pending) + const parentSettled = settled[0]! + const parentResult = parentSettled.status === 'fulfilled' ? parentSettled.value : undefined + const acknowledged = bracket.children.flatMap((child, i) => + settled[i + 1]?.status === 'fulfilled' + ? [{ orderId: String(child.orderId), kind: child.kind }] + : [], + ) + const failure = settled.find( + (entry): entry is PromiseRejectedResult => entry.status === 'rejected', + ) + + if (!failure) { + return { + success: true, + orderId: String(bracket.parentId), + orderState: parentResult!.orderState, + legs: acknowledged, + } + } + + // A filled entry is a real position, so cancelling the surviving legs + // would strip its protection. Report the entry plus the legs that landed. + if (parentResult && FILLED_PARENT_STATUSES.has(parentResult.orderState.status)) { + return { + success: true, + orderId: String(bracket.parentId), + orderState: parentResult.orderState, + legs: acknowledged, + message: `Entry ${bracket.parentId} is ${parentResult.orderState.status} but ${bracket.children.length - acknowledged.length} protective leg(s) were not acknowledged (${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}). Verify protection at the venue.`, + } + } + + // Nothing filled: unwind the whole chain so no half-bracket rests. + throw failure.reason + } catch (err) { + this.cancelQuietly(ids) + throw err + } + } + + /** Best-effort unwind: a cancel that TWS refuses must not mask the cause. */ + private cancelQuietly(orderIds: number[]): void { + for (const id of orderIds) { + try { this.client.cancelOrder(id, new OrderCancel()) } catch { /* best-effort */ } + } + } + async modifyOrder(orderId: string, changes: Partial): Promise { try { this._ensureAlive() @@ -546,12 +619,23 @@ export class IbkrBroker implements IBroker { try { await this._ensureWriteAlive() const numericId = parseInt(orderId, 10) - const promise = this.bridge.requestOrder(numericId) + // A fill that beat the cancel must not resolve it, or the ledger records a + // cancelled order over an open position. + const promise = this.bridge.requestOrder(numericId, undefined, acceptsCancelStatus) this.client.cancelOrder(numericId, orderCancel ?? new OrderCancel()) - await promise + const result = await promise + const status = result.orderState?.status || 'Cancelled' const os = new OrderState() - os.status = 'Cancelled' + os.status = status + if (!CANCEL_CONFIRMED_STATUSES.has(status)) { + return { + success: false, + orderId, + orderState: os, + error: `Cancel of order ${orderId} was not confirmed — TWS reports status ${status}.`, + } + } return { success: true, orderId, orderState: os } } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) } diff --git a/services/uta/src/domain/trading/brokers/ibkr/README.md b/services/uta/src/domain/trading/brokers/ibkr/README.md index 2fe5afd6ef..1821f209bb 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/README.md +++ b/services/uta/src/domain/trading/brokers/ibkr/README.md @@ -90,6 +90,56 @@ Known-dead transport state reaches UTA through an explicit connection-state list 3. Error 1101/1102 nudges recovery immediately but does not mark the account alive; reconnect plus a private account read must still pass. 4. Place/modify/cancel performs a coalesced `reqCurrentTime` round-trip on the same socket immediately before transmission. A timeout marks the bridge dead and no order bytes are sent. +## Bracket orders (attached TP/SL) + +`placeOrder(contract, order, tpsl)` submits a native TWS bracket +(`OrderSamples.BracketOrder`): + +1. Parent keeps the caller's entry type, price, and TIF; `transmit=false`. +2. Opposite-side children: take-profit `LMT`, stop `STP` or `STP LMT`. +3. Children share an OCA group with `ocaType=1` (cancel-with-block). The + parent is **not** in that group, a parent fill would otherwise cancel + the protective legs. +4. Child TIF is `GTC` so protection survives a GTD/DAY entry fill. +5. Children set `overridePercentageConstraints` so a far target is not + rejected by the default percentage price band. +6. The last child has `transmit=true`, which sends the whole chain as one + unit. TWS holds the children until the parent fills; filling one child + cancels the other. + +`PlaceOrderResult.legs` carries the child ids so the ledger tracks them +from birth. After fill, raising or trailing a stop is `modifyOrder` on +that child id. `parentId` and the OCA group survive the merge. + +A standalone `--oca-group` without `ocaType` defaults to `ocaType=1` so a +post-fill STP+TP pair can rest together. Same-name OCA with type 0 is +ignored by TWS and looks like a second short. + +The entry must carry a share `totalQuantity`. A monetary-value +(`cashQty`) entry is refused: notional sizes an ENTRY, and an opposite-side +exit priced at the target is a different share count, so the position ends +up partly uncovered or flipped short. TWS also rejects a notional STP leg +(`10244`). + +If a child acknowledgement fails while the parent is still unfilled, the +whole chain is cancelled. If the parent already filled, the surviving legs +are kept and `PlaceOrderResult.message` records the missing protection: +cancelling there would strip a real position and report that nothing was +placed. + +`openOrder` status `Inactive` does not immediately complete `requestOrder`. +TWS uses that status for untransmitted (`transmit=false`) holds, for +exchange-closed/precautionary holds, and for rejects. It is parked for a +short grace window so a reject's `error()` (or a later live status) wins; +if neither arrives, the hold itself is the answer, because a live held +order must not surface as a bare NETWORK timeout with no cancel issued. + +`cancelOrder` completes only on a cancel-confirming status +(`Cancelled` / `ApiCancelled` / `PendingCancel`) via the `accepts` +predicate on `requestOrder`. Accepting any live status would let a fill +that beat the cancel resolve it, and the ledger would record a cancelled +order over an open position. + ## Known Limitations 1. **Non-option position price staleness**: Stocks, futures, FX, warrants, and bonds still use `updatePortfolio()` marks. Only `OPT`/`FOP` rows receive the bounded snapshot-midpoint overlay. diff --git a/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.spec.ts new file mode 100644 index 0000000000..11bbfe07e7 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.spec.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' +import { Order, UNSET_DECIMAL } from '@traderalice/ibkr' +import { BrokerError } from '../types.js' +import { + applyStandaloneOcaType, + buildIbkrBracket, + refuseBracketOcaGroup, + IBKR_OCA_CANCEL_WITH_BLOCK, +} from './ibkr-bracket.js' + +function limitBuy(): Order { + const order = new Order() + order.action = 'BUY' + order.orderType = 'LMT' + order.totalQuantity = new Decimal(3) + order.lmtPrice = new Decimal('957.32') + order.tif = 'GTD' + order.goodTillDate = '20260904 16:00:00' + order.transmit = true + return order +} + +function sequentialIds(start = 10): () => number { + let next = start + return () => next++ +} + +describe('buildIbkrBracket', () => { + it('emits parent + TP + SL with the official transmit chain', () => { + const parentOrder = limitBuy() + const bracket = buildIbkrBracket( + parentOrder, + { takeProfit: { price: '1153.60' }, stopLoss: { price: '887.60' } }, + sequentialIds(10), + ) + + expect(bracket.parentId).toBe(10) + expect(bracket.parent.orderId).toBe(10) + expect(bracket.parent.transmit).toBe(false) + expect(bracket.parent.orderType).toBe('LMT') + expect(bracket.parent.lmtPrice.equals(new Decimal('957.32'))).toBe(true) + expect(bracket.parent.tif).toBe('GTD') + expect(bracket.parent.ocaGroup).toBe('') + expect(bracket.parent.ocaType).toBe(0) + + expect(bracket.children).toHaveLength(2) + const [tp, sl] = bracket.children + expect(tp).toMatchObject({ orderId: 11, kind: 'takeProfit' }) + expect(sl).toMatchObject({ orderId: 12, kind: 'stopLoss' }) + + expect(tp!.order.action).toBe('SELL') + expect(tp!.order.orderType).toBe('LMT') + expect(tp!.order.lmtPrice.equals(new Decimal('1153.60'))).toBe(true) + expect(tp!.order.parentId).toBe(10) + expect(tp!.order.transmit).toBe(false) + expect(tp!.order.tif).toBe('GTC') + expect(tp!.order.ocaGroup).toBe('uta-br-10') + expect(tp!.order.ocaType).toBe(IBKR_OCA_CANCEL_WITH_BLOCK) + expect(tp!.order.overridePercentageConstraints).toBe(true) + expect(tp!.order.totalQuantity.equals(new Decimal(3))).toBe(true) + expect(tp!.order.goodTillDate).toBe('') + + expect(sl!.order.action).toBe('SELL') + expect(sl!.order.orderType).toBe('STP') + expect(sl!.order.auxPrice.equals(new Decimal('887.60'))).toBe(true) + expect(sl!.order.parentId).toBe(10) + expect(sl!.order.transmit).toBe(true) + expect(sl!.order.ocaGroup).toBe(tp!.order.ocaGroup) + expect(sl!.order.ocaType).toBe(IBKR_OCA_CANCEL_WITH_BLOCK) + expect(sl!.order.overridePercentageConstraints).toBe(true) + }) + + it('does not mutate the caller order', () => { + const parentOrder = limitBuy() + buildIbkrBracket(parentOrder, { takeProfit: { price: '120' } }, sequentialIds()) + expect(parentOrder.transmit).toBe(true) + expect(parentOrder.orderId).toBe(0) + expect(parentOrder.ocaGroup).toBe('') + }) + + it('flips a SELL parent to BUY children', () => { + const parent = limitBuy() + parent.action = 'SELL' + const bracket = buildIbkrBracket(parent, { stopLoss: { price: '110' } }, sequentialIds()) + expect(bracket.children[0]!.order.action).toBe('BUY') + expect(bracket.children[0]!.order.transmit).toBe(true) + }) + + it('uses STP LMT when the stop has a limitPrice', () => { + const bracket = buildIbkrBracket( + limitBuy(), + { stopLoss: { price: '887.60', limitPrice: '886.00' } }, + sequentialIds(), + ) + const sl = bracket.children[0]!.order + expect(sl.orderType).toBe('STP LMT') + expect(sl.auxPrice.equals(new Decimal('887.60'))).toBe(true) + expect(sl.lmtPrice.equals(new Decimal('886.00'))).toBe(true) + }) + + it('transmits the only child when just a take-profit is attached', () => { + const bracket = buildIbkrBracket( + limitBuy(), + { takeProfit: { price: '120' } }, + sequentialIds(5), + ) + expect(bracket.children).toHaveLength(1) + expect(bracket.children[0]).toMatchObject({ orderId: 6, kind: 'takeProfit' }) + expect(bracket.parent.transmit).toBe(false) + expect(bracket.children[0]!.order.transmit).toBe(true) + }) + + it('refuses a caller ocaGroup instead of moving it onto the children', () => { + const parent = limitBuy() + parent.ocaGroup = 'swing-mu' + expect(() => + buildIbkrBracket( + parent, + { takeProfit: { price: '120' }, stopLoss: { price: '90' } }, + sequentialIds(), + ), + ).toThrow(/mints its own OCA group.*"swing-mu"/s) + }) + + it('mints its own group per bracket and keeps the parent out of it', () => { + const bracket = buildIbkrBracket( + limitBuy(), + { takeProfit: { price: '120' }, stopLoss: { price: '90' } }, + sequentialIds(40), + ) + expect(bracket.parent.ocaGroup).toBe('') + expect(bracket.children[0]!.order.ocaGroup).toBe('uta-br-40') + expect(bracket.children[1]!.order.ocaGroup).toBe('uta-br-40') + }) + + it('copies outsideRth and account onto children', () => { + const parent = limitBuy() + parent.outsideRth = true + parent.account = 'DU123' + const bracket = buildIbkrBracket(parent, { takeProfit: { price: '120' } }, sequentialIds()) + expect(bracket.children[0]!.order.outsideRth).toBe(true) + expect(bracket.children[0]!.order.account).toBe('DU123') + }) + + it('refuses a monetary-value entry that has no share quantity', () => { + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.cashQty = new Decimal('5000') + + expect(() => + buildIbkrBracket(order, { stopLoss: { price: '90' } }, sequentialIds(10)), + ).toThrow(/monetary-value|totalQuantity/i) + }) + + it('never forwards cashQty to a protective child', () => { + const order = limitBuy() + order.cashQty = new Decimal('5000') + + const bracket = buildIbkrBracket( + order, + { takeProfit: { price: '1153.60' }, stopLoss: { price: '887.60' } }, + sequentialIds(10), + ) + + for (const child of bracket.children) { + expect(child.order.cashQty.equals(UNSET_DECIMAL)).toBe(true) + expect(child.order.totalQuantity.equals(new Decimal(3))).toBe(true) + } + }) +}) + +describe('refuseBracketOcaGroup', () => { + it('leaves a standalone ocaGroup order alone', () => { + const order = limitBuy() + order.ocaGroup = 'swing-mu' + expect(() => refuseBracketOcaGroup(order, undefined)).not.toThrow() + expect(() => refuseBracketOcaGroup(order, {})).not.toThrow() + expect(order.ocaGroup).toBe('swing-mu') + }) + + it('refuses as a CONFIG error so the write path does not fold it into a venue rejection', () => { + const order = limitBuy() + order.ocaGroup = 'swing-mu' + let thrown: unknown + try { + refuseBracketOcaGroup(order, { stopLoss: { price: '90' } }) + } catch (err) { + thrown = err + } + expect(thrown).toBeInstanceOf(BrokerError) + expect((thrown as BrokerError).code).toBe('CONFIG') + }) +}) + +describe('applyStandaloneOcaType', () => { + it('defaults ocaType to cancel-with-block when a group is set', () => { + const order = limitBuy() + order.ocaGroup = 'swing-mu' + const sent = applyStandaloneOcaType(order) + expect(sent).not.toBe(order) + expect(sent.ocaType).toBe(IBKR_OCA_CANCEL_WITH_BLOCK) + expect(order.ocaType).toBe(0) + }) + + it('leaves orders without an OCA group untouched', () => { + const order = limitBuy() + expect(applyStandaloneOcaType(order)).toBe(order) + }) + + it('preserves an explicit non-zero ocaType', () => { + const order = limitBuy() + order.ocaGroup = 'swing-mu' + order.ocaType = 2 + expect(applyStandaloneOcaType(order)).toBe(order) + expect(order.ocaType).toBe(2) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts b/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts new file mode 100644 index 0000000000..43a5a6a2ca --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts @@ -0,0 +1,136 @@ +/** + * IBKR native bracket: parent plus attached opposite-side children, mirroring + * TWS API `OrderSamples.BracketOrder`. The parent must stay out of the + * children's OCA group, or its own fill cancels them. + */ + +import Decimal from 'decimal.js' +import { Order, UNSET_DECIMAL } from '@traderalice/ibkr' +import { BrokerError, type PlaceOrderLeg, type TpSlParams } from '../types.js' + +/** IBKR ocaType 1 = CANCEL_WITH_BLOCK. */ +export const IBKR_OCA_CANCEL_WITH_BLOCK = 1 + +export interface IbkrBracketChild { + orderId: number + kind: PlaceOrderLeg['kind'] + order: Order +} + +export interface IbkrBracket { + parentId: number + parent: Order + children: IbkrBracketChild[] +} + +export function cloneOrder(order: Order): Order { + return Object.assign(new Order(), order) +} + +function exitAction(action: string): 'BUY' | 'SELL' { + return action === 'BUY' ? 'SELL' : 'BUY' +} + +function applySharedChildFields( + child: Order, + parent: Order, + parentId: number, + ocaGroup: string, +): void { + child.action = exitAction(parent.action) + child.parentId = parentId + child.transmit = false + child.tif = 'GTC' + child.ocaGroup = ocaGroup + child.ocaType = IBKR_OCA_CANCEL_WITH_BLOCK + child.overridePercentageConstraints = true + child.totalQuantity = parent.totalQuantity + // `cashQty` is not forwarded: a notional exit is a different share count + // than the entry bought, and TWS rejects a notional STP leg (`10244`). + if (parent.outsideRth) child.outsideRth = true + if (parent.account) child.account = parent.account +} + +/** + * Refuses a caller OCA group on a bracket entry: the bracket mints its own + * group for the exits, and the entry has to stay out of it. Call it before the + * write's try/catch so the error propagates. + */ +export function refuseBracketOcaGroup(parentOrder: Order, tpsl: TpSlParams | undefined): void { + if (!parentOrder.ocaGroup) return + if (!tpsl?.takeProfit && !tpsl?.stopLoss) return + throw new BrokerError( + 'CONFIG', + `IBKR bracket mints its own OCA group for the exit legs; ocaGroup "${parentOrder.ocaGroup}" cannot be combined with attached TP/SL. ` + + 'Place the entry with ocaGroup and no TP/SL, or let the bracket own its group.', + ) +} + +/** + * Builds a parent plus 1-2 protective children, allocating ids parent-first via + * `nextOrderId`. Does not mutate `parentOrder`. + */ +export function buildIbkrBracket( + parentOrder: Order, + tpsl: TpSlParams, + nextOrderId: () => number, +): IbkrBracket { + if (!tpsl.takeProfit && !tpsl.stopLoss) { + throw new Error('buildIbkrBracket requires takeProfit and/or stopLoss') + } + if (parentOrder.totalQuantity.equals(UNSET_DECIMAL) || parentOrder.totalQuantity.lte(0)) { + throw new Error( + 'IBKR attached TP/SL needs a share totalQuantity on the entry — a monetary-value (cashQty) entry cannot size its protective legs. Place the entry first, then attach STP/LMT protection to the filled quantity.', + ) + } + refuseBracketOcaGroup(parentOrder, tpsl) + + const parentId = nextOrderId() + const parent = cloneOrder(parentOrder) + parent.orderId = parentId + parent.transmit = false + const ocaGroup = `uta-br-${parentId}` + parent.ocaGroup = '' + parent.ocaType = 0 + + const children: IbkrBracketChild[] = [] + + if (tpsl.takeProfit) { + const orderId = nextOrderId() + const order = new Order() + order.orderId = orderId + order.orderType = 'LMT' + order.lmtPrice = new Decimal(tpsl.takeProfit.price) + applySharedChildFields(order, parent, parentId, ocaGroup) + children.push({ orderId, kind: 'takeProfit', order }) + } + + if (tpsl.stopLoss) { + const orderId = nextOrderId() + const order = new Order() + order.orderId = orderId + if (tpsl.stopLoss.limitPrice) { + order.orderType = 'STP LMT' + order.auxPrice = new Decimal(tpsl.stopLoss.price) + order.lmtPrice = new Decimal(tpsl.stopLoss.limitPrice) + } else { + order.orderType = 'STP' + order.auxPrice = new Decimal(tpsl.stopLoss.price) + } + applySharedChildFields(order, parent, parentId, ocaGroup) + children.push({ orderId, kind: 'stopLoss', order }) + } + + children[children.length - 1]!.order.transmit = true + return { parentId, parent, children } +} + +/** Same-name OCA with type 0 is ignored by TWS and looks like a second short. */ +export function applyStandaloneOcaType(order: Order): Order { + if (order.ocaGroup && order.ocaType === 0) { + const clone = cloneOrder(order) + clone.ocaType = IBKR_OCA_CANCEL_WITH_BLOCK + return clone + } + return order +} 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 377caa369c..ecf56b3acb 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 @@ -2,8 +2,8 @@ import { createServer, type Socket } from 'node:net' import { describe, it, expect, vi } from 'vitest' import Decimal from 'decimal.js' -import { Connection, Contract, EClient, makeField, makeMsg, NO_VALID_ID, TickTypeEnum } from '@traderalice/ibkr' -import { RequestBridge } from './request-bridge.js' +import { Connection, Contract, EClient, makeField, makeMsg, NO_VALID_ID, Order, OrderState, TickTypeEnum } from '@traderalice/ibkr' +import { RequestBridge, acceptsCancelStatus } from './request-bridge.js' function stk(conId: number, symbol: string): Contract { const c = new Contract() @@ -317,3 +317,97 @@ describe('RequestBridge — currency-aware account values (issue #295)', () => { expect(b.getAccountCache()!.values.get('ExchangeRate:USD')).toBeUndefined() }) }) + +describe('RequestBridge — placeOrder Inactive hold', () => { + function state(status: string): OrderState { + const os = new OrderState() + os.status = status + return os + } + + it('does not resolve requestOrder on Inactive; error() then carries the venue message', async () => { + const bridge = new RequestBridge() + const pending = bridge.requestOrder(19, 1_000) + bridge.openOrder(19, new Contract(), new Order(), state('Inactive')) + + let settled = false + void pending.then(() => { settled = true }, () => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + + bridge.error(19, 0, 202, 'Order Canceled - reason:') + await expect(pending).rejects.toThrow(/IBKR error 202.*Order Canceled/) + }) + + it('resolves after a later live openOrder', async () => { + const bridge = new RequestBridge() + const pending = bridge.requestOrder(10, 1_000) + const contract = new Contract() + const order = new Order() + bridge.openOrder(10, contract, order, state('Inactive')) + bridge.openOrder(10, contract, order, state('PreSubmitted')) + await expect(pending).resolves.toMatchObject({ orderState: { status: 'PreSubmitted' } }) + }) + + it('resolves a live orderStatus after skipping Inactive', async () => { + const bridge = new RequestBridge() + const pending = bridge.requestOrder(10, 1_000) + bridge.openOrder(10, new Contract(), new Order(), state('Inactive')) + bridge.orderStatus( + 10, 'Submitted', new Decimal(0), new Decimal(1), + 0, 0, 0, 0, 0, '', 0, + ) + await expect(pending).resolves.toMatchObject({ orderState: { status: 'Submitted' } }) + }) +}) + +describe('RequestBridge — order request status gating', () => { + function state(status: string): OrderState { + const os = new OrderState() + os.status = status + return os + } + + it('does not complete a cancel request on a live status', async () => { + const bridge = new RequestBridge() + const pending = bridge.requestOrder(31, 1_000, acceptsCancelStatus) + + bridge.orderStatus(31, 'Filled', new Decimal(5), new Decimal(0), 101, 0, 0, 0, 0, '', 0) + let settled = false + void pending.then(() => { settled = true }, () => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + + bridge.orderStatus(31, 'Cancelled', new Decimal(0), new Decimal(5), 0, 0, 0, 0, 0, '', 0) + await expect(pending).resolves.toMatchObject({ orderState: { status: 'Cancelled' } }) + }) + + it('answers with the Inactive hold when no error or live status follows', async () => { + // TWS marks exchange-closed and precautionary holds Inactive with no + // error() callback. + vi.useFakeTimers() + try { + const bridge = new RequestBridge() + const pending = bridge.requestOrder(32, 30_000) + bridge.openOrder(32, new Contract(), new Order(), state('Inactive')) + await vi.advanceTimersByTimeAsync(2_000) + await expect(pending).resolves.toMatchObject({ orderState: { status: 'Inactive' } }) + } finally { + vi.useRealTimers() + } + }) + + it('lets a later live status beat the parked Inactive hold', async () => { + vi.useFakeTimers() + try { + const bridge = new RequestBridge() + const pending = bridge.requestOrder(33, 30_000) + bridge.openOrder(33, new Contract(), new Order(), state('Inactive')) + bridge.openOrder(33, new Contract(), new Order(), state('PreSubmitted')) + await vi.advanceTimersByTimeAsync(2_000) + await expect(pending).resolves.toMatchObject({ orderState: { status: 'PreSubmitted' } }) + } finally { + vi.useRealTimers() + } + }) +}) 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 f67359df00..9a12ad014b 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -40,6 +40,30 @@ const DEFAULT_TIMEOUT_MS = 10_000 const SNAPSHOT_TIMEOUT_MS = 12_500 const ACCOUNT_READY_TIMEOUT_MS = 20_000 +/** + * How long an `Inactive` status is parked before it is accepted as the answer. + * TWS uses `Inactive` for both a reject and a legitimate hold, so the window + * lets a reject's `error()` arrive first. + */ +const INACTIVE_GRACE_MS = 1_500 + +const acceptsLiveStatus = (status: string): boolean => status !== 'Inactive' + +/** + * A cancel must not be completed by `Submitted`/`Filled`/`PreSubmitted`, or a + * fill that beat the cancel is recorded as a cancel. + */ +const CANCEL_CONFIRMING_STATUSES = new Set(['Cancelled', 'ApiCancelled', 'PendingCancel']) +export const acceptsCancelStatus = (status: string): boolean => + CANCEL_CONFIRMING_STATUSES.has(status) + +interface PendingOrderRequest extends PendingRequest { + /** Which callback status completes this request. */ + accepts: (status: string) => boolean + /** Deferred `Inactive` answer, resolved if nothing better arrives. */ + hold?: { value: CollectedOpenOrder; timer: ReturnType } +} + export class RequestBridge extends DefaultEWrapper { // ---- State ---- private nextReqId_ = 10_000 @@ -56,7 +80,7 @@ export class RequestBridge extends DefaultEWrapper { private snapshotResolveOnBidAsk = new Set() // ---- Mode B: orderId-based pending requests ---- - private orderPending = new Map>() + private orderPending = new Map() // ---- Mode C: single-slot collectors ---- private openOrdersCollector: { @@ -230,14 +254,21 @@ export class RequestBridge extends DefaultEWrapper { // ---- Mode B: orderId-based requests ---- - /** Register a pending order request (waits for openOrder callback). */ - requestOrder(orderId: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + /** + * Registers a pending order request. Cancels must pass `acceptsCancelStatus` + * so a fill cannot be mistaken for a cancel. + */ + requestOrder( + orderId: number, + timeoutMs = DEFAULT_TIMEOUT_MS, + accepts: (status: string) => boolean = acceptsLiveStatus, + ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.orderPending.delete(orderId) + this.clearOrderPending(orderId) reject(new BrokerError('NETWORK', `Order ${orderId} timed out after ${timeoutMs}ms`)) }, timeoutMs) - this.orderPending.set(orderId, { resolve, reject, timer }) + this.orderPending.set(orderId, { resolve, reject, timer, accepts }) }) } @@ -378,20 +409,41 @@ export class RequestBridge extends DefaultEWrapper { this.resolveRequest(reqId, this.collectors.get(reqId) ?? []) } - private resolveOrderRequest(orderId: number, value: CollectedOpenOrder): void { + /** Drop a pending order request and every timer it owns. */ + private clearOrderPending(orderId: number): PendingOrderRequest | undefined { const entry = this.orderPending.get(orderId) - if (!entry) return + if (!entry) return undefined clearTimeout(entry.timer) + if (entry.hold) clearTimeout(entry.hold.timer) this.orderPending.delete(orderId) - entry.resolve(value) + return entry + } + + private resolveOrderRequest(orderId: number, value: CollectedOpenOrder): void { + this.clearOrderPending(orderId)?.resolve(value) } private rejectOrderRequest(orderId: number, error: Error): void { + this.clearOrderPending(orderId)?.reject(error) + } + + /** + * Routes an openOrder / orderStatus answer to its pending request. An + * `Inactive` status is parked for `INACTIVE_GRACE_MS` so a reject's `error()` + * or a later live status can win. + */ + private answerOrderRequest(orderId: number, value: CollectedOpenOrder): void { const entry = this.orderPending.get(orderId) if (!entry) return - clearTimeout(entry.timer) - this.orderPending.delete(orderId) - entry.reject(error) + if (entry.accepts(value.orderState.status)) { + this.resolveOrderRequest(orderId, value) + return + } + if (value.orderState.status !== 'Inactive' || entry.hold) return + entry.hold = { + value, + timer: setTimeout(() => this.resolveOrderRequest(orderId, value), INACTIVE_GRACE_MS), + } } private rejectAll(error: Error): void { @@ -406,6 +458,7 @@ export class RequestBridge extends DefaultEWrapper { for (const [, entry] of this.orderPending) { clearTimeout(entry.timer) + if (entry.hold) clearTimeout(entry.hold.timer) entry.reject(error) } this.orderPending.clear() @@ -738,10 +791,7 @@ export class RequestBridge extends DefaultEWrapper { override openOrder(orderId: number, contract: Contract, order: Order, orderState: OrderState): void { const collected: CollectedOpenOrder = { contract, order, orderState } - // Route to pending order request (placeOrder/modifyOrder) - if (this.orderPending.has(orderId)) { - this.resolveOrderRequest(orderId, collected) - } + this.answerOrderRequest(orderId, collected) // Also collect for openOrders batch this.openOrdersCollector?.orders.push(collected) @@ -765,16 +815,15 @@ export class RequestBridge extends DefaultEWrapper { this.fillData_.set(orderId, { filled, avgFillPrice }) } - // For cancel requests, we wait for status 'Cancelled' - if (this.orderPending.has(orderId) && status === 'Cancelled') { - const os = new OrderStateClass() - os.status = 'Cancelled' - this.resolveOrderRequest(orderId, { - contract: new ContractClass(), - order: new OrderClass(), - orderState: os, - }) - } + if (!this.orderPending.has(orderId)) return + + const os = new OrderStateClass() + os.status = status + this.answerOrderRequest(orderId, { + contract: new ContractClass(), + order: new OrderClass(), + orderState: os, + }) } override openOrderEnd(): void { diff --git a/services/uta/src/domain/trading/git/TradingGit.spec.ts b/services/uta/src/domain/trading/git/TradingGit.spec.ts index f1741882a8..18a0a600e3 100644 --- a/services/uta/src/domain/trading/git/TradingGit.spec.ts +++ b/services/uta/src/domain/trading/git/TradingGit.spec.ts @@ -599,6 +599,43 @@ describe('TradingGit', () => { } }) + it('strips sentinels from the OrderState IBKR sends back', async () => { + const inboundConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: '1', + orderState: { + status: 'Inactive', + suggestedSize: UNSET_DECIMAL_STR, + commissionAndFees: Number.MAX_VALUE, + initMarginAfter: '', + rejectReason: '', + }, + }), + }) + const gitInbound = new TradingGit(inboundConfig) + gitInbound.add(buyOp()) + gitInbound.commit('rejected outside RTH') + await gitInbound.push(gitInbound.status().pendingHash!) + + const head = gitInbound.status().head! + for (const blob of [gitInbound.show(head), gitInbound.exportState()]) { + const serialised = JSON.stringify(blob) + expect(serialised).not.toContain(UNSET_DECIMAL_STR) + expect(serialised).not.toContain('1.7976931348623157e+308') + } + const commit = gitInbound.show(head)! + expect(commit.results[0].orderState?.status).toBe('Inactive') + }) + + it('strips sentinels from the Contract on a projected operation', () => { + // Contract.strike defaults to UNSET_DOUBLE. + git.add(buyOp()) + const op = git.status().staged[0] as Extract + expect(op.contract).not.toHaveProperty('strike') + expect(op.contract.symbol).toBe('AAPL') + }) + it('modifyOrder.changes also strips sentinels', () => { const partialChanges = new Order() partialChanges.lmtPrice = new Decimal('150') diff --git a/services/uta/src/domain/trading/git/TradingGit.ts b/services/uta/src/domain/trading/git/TradingGit.ts index 19c78f286e..c5d4ccc3a2 100644 --- a/services/uta/src/domain/trading/git/TradingGit.ts +++ b/services/uta/src/domain/trading/git/TradingGit.ts @@ -547,17 +547,31 @@ export class TradingGit implements ITradingGit { // raw Order instances stay private to staging / push internals, never // observed by external callers (UI, MCP, c.json, on-disk commit.json). private projectOperation(op: Operation): Operation { + // Contract carries its own sentinels: `strike` defaults to UNSET_DOUBLE. if (op.action === 'placeOrder' || op.action === 'observeExternalOrder') { - return { ...op, order: OrderHelper.toWire(op.order) as unknown as Order } + return { + ...op, + contract: OrderHelper.scrub(op.contract), + order: OrderHelper.toWire(op.order) as unknown as Order, + } } if (op.action === 'modifyOrder') { return { ...op, changes: OrderHelper.toWire(op.changes) as unknown as Partial } } + if (op.action === 'closePosition') { + return { ...op, contract: OrderHelper.scrub(op.contract) } + } return op } + // Results carry sentinels too, scrubbed here rather than at every call site + // that builds one. private projectCommit(commit: GitCommit): GitCommit { - return { ...commit, operations: commit.operations.map((op) => this.projectOperation(op)) } + return { + ...commit, + operations: commit.operations.map((op) => this.projectOperation(op)), + results: commit.results.map((result) => OrderHelper.scrub(result)), + } } // ==================== Serialization ==================== From 424533c10474a28d32164e22a61f309cfc18afff Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 19:59:55 -0700 Subject: [PATCH 2/9] fix(uta): keep recovery alive when a stale success or a wedged probe races transport death An IB Gateway restart left ibkr-tws-c6e6feb7 offline for 90 minutes with `recovering:false`, `consecutiveFailures:0`, and `lastSuccessAt` 256ms AFTER `lastFailureAt` -- no recovery in flight and nothing left to arm one. Two defects combined: - `_onSuccess` never repaired `_currentReach`. A read that entered the broker before the transport-dead event and resolved after it reset the failure counter, cleared `_recovering`, and cancelled the retry timer while reach stayed pinned at 'down' -- and `health` derives 'offline' from reach alone. The account was then unrecoverable: the order-sync poller skips non-healthy accounts and `nudgeRecovery()` no-ops when not already recovering, so only a process restart fixed it. - A reach probe that was in flight when the transport died reported its result anyway, letting a lapsed success clobber reach back up the ladder. `_onSuccess` now promotes reach off 'down' (a completed round-trip proves the transport is up) and only dismantles the recovery machinery when the target reach is actually satisfied, re-arming recovery otherwise. Reach probes in `_connect` and the recovery loop run through `_attemptReachGuarded`, which discards a result whose transport generation lapsed mid-probe. A recovery attempt also only re-arms its retry timer AFTER the probe promise settles, so a probe that never settles silently terminates auto-recovery: `recovering` stays true, no timer is armed, health freezes, and only a process restart repairs it. That is reachable today. `Connection.connect()` resolved on 'connect' and rejected on 'error', but a socket destroyed while still in SYN_SENT emits only 'close' -- and destroying it is exactly what the request bridge does when its own 15s connect deadline expires. The connect promise then stayed pending forever, wedging `EClient.connect` -> `waitForConnect` -> `IbkrBroker.init` -> the UTA recovery loop. Observed against an IB Gateway container recreated mid-login: recovery attempt 1 logged "down", attempt 2 never logged anything for 5+ minutes. Settle `Connection.connect()` on socket close, and make the loop itself self-bounding regardless of what a broker pack does: race each probe against RECOVERY_PROBE_TIMEOUT_MS (45s, above IBKR's 15s handshake + 20s account download), bump the transport epoch so the abandoned probe's answer is discarded, tear the broker down so the next attempt starts clean, and re-schedule. --- packages/ibkr/src/connection.spec.ts | 63 ++++++++++ packages/ibkr/src/connection.ts | 16 ++- .../trading/UnifiedTradingAccount.spec.ts | 110 ++++++++++++++++++ .../domain/trading/UnifiedTradingAccount.ts | 70 ++++++++++- 4 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 packages/ibkr/src/connection.spec.ts diff --git a/packages/ibkr/src/connection.spec.ts b/packages/ibkr/src/connection.spec.ts new file mode 100644 index 0000000000..19e1d962bc --- /dev/null +++ b/packages/ibkr/src/connection.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import net from 'node:net' +import { EventEmitter } from 'node:events' +import { Connection } from './connection.js' + +/** A net.Socket stand-in that never completes its TCP connect, as a recreated + * IB Gateway container looks while its port is not yet reachable. */ +function stubPendingSocket(): EventEmitter & { destroy: () => void } { + const socket = new EventEmitter() as EventEmitter & { + connect: (port: number, host: string, cb: () => void) => void + destroy: () => void + write: () => boolean + } + socket.connect = () => { /* stays in SYN_SENT: no 'connect', no 'error' */ } + socket.destroy = () => { socket.emit('close') } + socket.write = () => true + // Callers use `new net.Socket()`, so the implementation must be + // constructible and an arrow function will not do. + vi.spyOn(net, 'Socket').mockImplementation(function () { return socket as unknown as net.Socket }) + return socket +} + +describe('Connection.connect — terminal settlement', () => { + afterEach(() => { vi.restoreAllMocks() }) + + // destroy() emits 'close' but not 'error', so a promise listening only for + // 'connect'/'error' never settles. + it('rejects a still-connecting attempt when disconnect() destroys the socket', async () => { + stubPendingSocket() + const conn = new Connection('127.0.0.1', 4002) + conn.wrapper = { error: () => {}, connectionClosed: () => {} } + + const attempt = conn.connect() + const settled = vi.fn() + void attempt.then(settled, settled) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + conn.disconnect() + await expect(attempt).rejects.toThrow() + }) + + it('rejects when the peer closes the socket before the connect callback', async () => { + const socket = stubPendingSocket() + const conn = new Connection('127.0.0.1', 4002) + conn.wrapper = { error: () => {}, connectionClosed: () => {} } + + const attempt = conn.connect() + socket.emit('close') + await expect(attempt).rejects.toThrow() + }) + + it('still resolves normally once the socket connects', async () => { + const socket = stubPendingSocket() as EventEmitter & { + connect: (port: number, host: string, cb: () => void) => void + } + socket.connect = (_port, _host, cb) => { cb() } + const conn = new Connection('127.0.0.1', 4002) + conn.wrapper = { error: () => {}, connectionClosed: () => {} } + + await expect(conn.connect()).resolves.toBeUndefined() + }) +}) diff --git a/packages/ibkr/src/connection.ts b/packages/ibkr/src/connection.ts index 789ad791fd..4a9b5ae441 100644 --- a/packages/ibkr/src/connection.ts +++ b/packages/ibkr/src/connection.ts @@ -32,6 +32,15 @@ export class Connection extends EventEmitter { connect(): Promise { return new Promise((resolve, reject) => { + // The attempt must settle on every terminal socket outcome: destroying a + // socket still in SYN_SENT emits 'close' without 'error', and a promise + // watching only 'connect'/'error' would hang every awaiting caller. + let settled = false + const settle = (fn: () => void): void => { + if (settled) return + settled = true + fn() + } try { this.socket = new net.Socket() } catch { @@ -47,6 +56,9 @@ export class Connection extends EventEmitter { }) this.socket.on('close', () => { + // Reject before the cleanup guard below, which returns early when + // disconnect() already nulled the field. + settle(() => reject(new Error(CONNECT_FAIL.msg()))) // Guard: if socket is already null, disconnect() already handled cleanup. // Without this check, connectionClosed() would be called twice when // disconnect() is invoked (once by disconnect, once by the close event). @@ -69,14 +81,14 @@ export class Connection extends EventEmitter { }) this.socket.connect(this.port, this.host, () => { - resolve() + settle(resolve) }) this.socket.once('error', (err: Error) => { if (this.wrapper) { this.wrapper.error(NO_VALID_ID, currentTimeMillis(), CONNECT_FAIL.code(), CONNECT_FAIL.msg()) } - reject(err) + settle(() => reject(err)) }) }) } diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts index 0492cc8482..818be11c16 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -1177,6 +1177,83 @@ describe('UTA — health tracking', () => { await uta.close() }) + it('re-arms recovery when a stale in-flight success lands after a transport-dead event', async () => { + const broker = new MockBroker() + let connectionListener: (event: { state: 'alive' | 'dead' | 'restored'; error?: string }) => void = () => { + throw new Error('connection-state listener was not registered') + } + ;(broker as unknown as { setConnectionStateListener: unknown }).setConnectionStateListener = ( + listener: typeof connectionListener | null, + ) => { if (listener) connectionListener = listener } + const { uta } = createUTA(broker) // funded → target "readable" + await flush() + expect(uta.health).toBe('healthy') + + // The read passes the offline gate before the transport dies, so a success + // landing afterwards still reaches _onSuccess. + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + const realGetAccount = broker.getAccount.bind(broker) + ;(broker as unknown as { getAccount: unknown }).getAccount = async () => { + await gate + return realGetAccount() + } + const inFlight = uta.getAccount() + + connectionListener({ state: 'dead', error: 'Write-path liveness probe failed' }) + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + + release() + await inFlight + + const info = uta.getHealthInfo() + expect(info.consecutiveFailures).toBe(0) + // The account may never end up offline with no recovery in flight. + if (info.status === 'offline' || info.status === 'degraded') { + expect(info.recovering).toBe(true) + } + expect(info.reach).not.toBe('down') + + await vi.advanceTimersByTimeAsync(60_000) + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) + await uta.close() + }) + + it('discards a recovery probe whose result predates a mid-probe transport death', async () => { + const broker = new MockBroker() + let connectionListener: (event: { state: 'alive' | 'dead' | 'restored'; error?: string }) => void = () => { + throw new Error('connection-state listener was not registered') + } + ;(broker as unknown as { setConnectionStateListener: unknown }).setConnectionStateListener = ( + listener: typeof connectionListener | null, + ) => { if (listener) connectionListener = listener } + // A probe that succeeds after its own socket died must not raise reach. + const realGetAccount = broker.getAccount.bind(broker) + let killed = false + ;(broker as unknown as { getAccount: unknown }).getAccount = async () => { + const result = await realGetAccount() + if (!killed) { + killed = true + connectionListener({ state: 'dead', error: 'gateway restarted mid-probe' }) + } + return result + } + const { uta } = createUTA(broker) + await flush() + + // The connect probe raced the death, so reach stays down and recovery armed. + expect(uta.reach).toBe('down') + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + + await vi.advanceTimersByTimeAsync(60_000) + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) + await uta.close() + }) + it('transitions healthy → degraded after 3 consecutive failures', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) @@ -1189,6 +1266,39 @@ describe('UTA — health tracking', () => { expect(uta.health).toBe('degraded') }) + it('keeps retrying when a broker probe never settles, and recovers when the venue returns', async () => { + const broker = new MockBroker() + const closeSpy = vi.spyOn(broker, 'close').mockResolvedValue(undefined) + const realInit = broker.init.bind(broker) + let hangsLeft = 2 + ;(broker as unknown as { init: () => Promise }).init = () => { + if (hangsLeft-- > 0) return new Promise(() => { /* wedged transport: never settles */ }) + return realInit() + } + const { uta } = createUTA(broker) // funded → target "readable" + uta.waitForConnect().catch(() => { /* initial connect is expected to fail */ }) + await flush() + + // Initial connect's probe is wedged: still "connecting", nothing decided. + expect(uta.getHealthInfo().connecting).toBe(true) + + // The 45s probe deadline abandons the wedged attempt and arms recovery. + await vi.advanceTimersByTimeAsync(45_000) + expect(uta.health).toBe('offline') + expect(uta.getHealthInfo().recovering).toBe(true) + expect(closeSpy).toHaveBeenCalled() + + // Recovery attempt 1 (t+5s) wedges too, and the loop must still come back. + await vi.advanceTimersByTimeAsync(5_000 + 45_000) + expect(uta.getHealthInfo().recovering).toBe(true) + + // Attempt 2 (t+10s backoff) meets a healthy venue. + await vi.advanceTimersByTimeAsync(10_000) + expect(uta.health).toBe('healthy') + expect(uta.getHealthInfo().recovering).toBe(false) + await uta.close() + }) + it('transitions degraded → offline after 6 consecutive failures', async () => { const broker = new MockBroker() const { uta } = createUTA(broker) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index aa3c6e655a..bc0e157d2f 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -107,6 +107,10 @@ export class UnifiedTradingAccount { * it and the read returns "connecting" instead of blocking on the whole init. * Bounds the cold-start first-read to this, not the full connect time. */ private static readonly CONNECT_GRACE_MS = 1_500 + /** Hard deadline for one capability-ladder probe. Larger than the slowest + * legitimate broker connect (IBKR: 15s handshake plus 20s account download) + * so it only ever fires on a genuine hang. */ + private static readonly RECOVERY_PROBE_TIMEOUT_MS = 45_000 private _consecutiveFailures = 0 private _lastError?: string @@ -115,6 +119,10 @@ export class UnifiedTradingAccount { private _recoveryTimer?: ReturnType private _recovering = false private _disabled = false + /** Monotonic transport generation, bumped whenever the broker reports the + * connection dead. A probe whose generation moved underneath it must discard + * its own result, or a success from a dead socket raises `_currentReach`. */ + private _transportEpoch = 0 /** True while the INITIAL broker connect is in flight (e.g. CCXT loadMarkets, * which can take tens of seconds). Reads during this window return fast with * a transient CONNECTING error instead of blocking on the slow connect — the @@ -310,6 +318,43 @@ export class UnifiedTradingAccount { } } + /** `_attemptReach` bound to the transport generation it started on. An answer + * describing a socket that has since died is downgraded to 'down' so the + * caller keeps pursuing the target. */ + private async _attemptReachGuarded(): Promise { + const epoch = this._transportEpoch + const reached = await this._attemptReach() + if (epoch === this._transportEpoch) return reached + console.warn(`UTA[${this.id}]: reach probe discarded — transport died mid-probe (probe said "${reached}")`) + return 'down' + } + + /** `_attemptReachGuarded` under a hard deadline. Always settles, because the + * caller re-arms the retry timer from here. */ + private async _probeReachBounded(): Promise { + let timer: ReturnType | undefined + const deadline = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), UnifiedTradingAccount.RECOVERY_PROBE_TIMEOUT_MS) + timer.unref?.() + }) + const probe = this._attemptReachGuarded() + const outcome = await Promise.race([probe, deadline]) + .finally(() => { if (timer) clearTimeout(timer) }) + if (outcome !== 'timeout') return outcome + + probe.catch(() => { /* abandoned; its answer is discarded by the epoch bump */ }) + this._transportEpoch++ + this._noteFailure(new BrokerError( + 'NETWORK', + `Broker probe did not settle within ${UnifiedTradingAccount.RECOVERY_PROBE_TIMEOUT_MS}ms`, + )) + console.warn(`UTA[${this.id}]: reach probe timed out — abandoning the attempt and resetting the transport`) + // Not awaited: a broker whose probe is wedged can wedge its close() too, and + // the retry schedule must not depend on that. + void Promise.resolve().then(() => this.broker.close()).catch(() => { /* already torn down */ }) + return 'down' + } + private _notePermanent(err: unknown): void { // Broker packs may carry their own physical copy of uta-protocol. Preserve // the structured BrokerError contract across that module boundary instead @@ -339,6 +384,7 @@ export class UnifiedTradingAccount { } if (this._disabled) return + this._transportEpoch++ this._currentReach = 'down' this._consecutiveFailures = UnifiedTradingAccount.OFFLINE_THRESHOLD this._lastError = event.error ?? 'Broker transport reported a dead connection' @@ -358,7 +404,7 @@ export class UnifiedTradingAccount { // CCXT loadMarkets is otherwise an invisible ~30s stall). const startedAt = Date.now() console.log(`UTA[${this.id}]: connecting (target ${this.targetReach})…`) - this._currentReach = await this._attemptReach() + this._currentReach = await this._probeReachBounded() // Initial connect has settled (reached, down, or disabled — _attemptReach // never throws). Clear the connecting gate now, BEFORE any _emitHealthChange // below, so the first health diff the UI receives reflects the real state. @@ -445,11 +491,23 @@ export class UnifiedTradingAccount { const prev = this.health this._consecutiveFailures = 0 this._lastSuccessAt = new Date() - if (this._recoveryTimer) { - clearTimeout(this._recoveryTimer) - this._recoveryTimer = undefined + // A completed round-trip proves the transport is up. Without this promotion + // a success landing after a 'dead' event leaves reach pinned at 'down', which + // `health` reads as permanently offline. + if (this._currentReach === 'down') this._currentReach = 'connected' + // Only a success that satisfies the target reach may dismantle the recovery + // machinery. Clearing it below target leaves nothing armed to retry, since + // `nudgeRecovery` no-ops when not already recovering. + if (this._reachedTarget()) { + if (this._recoveryTimer) { + clearTimeout(this._recoveryTimer) + this._recoveryTimer = undefined + } + this._recovering = false + if (prev !== this.health) this._emitHealthChange() + return } - this._recovering = false + if (!this._disabled && !this._recovering) this._startRecovery() if (prev !== this.health) this._emitHealthChange() } @@ -486,7 +544,7 @@ export class UnifiedTradingAccount { ) this._recoveryTimer = setTimeout(async () => { this._recoveryTimer = undefined - this._currentReach = await this._attemptReach() + this._currentReach = await this._probeReachBounded() if (this._disabled) { this._recovering = false console.warn(`UTA[${this.id}]: disabled — ${this._lastError}`) From 8f5c654bc9d1d5c8cac2a4e73336b5787e5af612 Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:00:21 -0700 Subject: [PATCH 3/9] fix(ibkr): make transport liveness a coarse failure detector The clientId-0 session was flapping every 1-3 minutes on a healthy gateway: a single unanswered currentTime probe tore the socket down while another client on the same gateway kept getting replies in ~1ms. Reference implementations use no application probe at all, so the probe has to be a failure detector, not a latency assertion. - Raise the write probe deadline to 10s and the heartbeat probe to 15s, require two consecutive misses (with no inbound traffic in between) before the heartbeat declares death, and retry a missed write probe once before refusing the write. The inbound-traffic exemption is bounded so a half-open socket that keeps pushing account updates cannot hide a dead write path forever (issue #294). - Give liveness probes and open-order sweeps independent deadlines. A probe coalesced onto an in-flight sweep inherited the sweep's deadline. Sweeps are single-flight and every joiner receives the full batch. - currentTime replies are matched FIFO to their waiters, and the "a reply is still owed" credit decays on a grace timer. A reply the gateway never sent used to eat the next probe's reply, starving every later probe. - waitForConnect requires managedAccounts as well as nextValidId; an empty account list no longer resolves the handshake as a non-retryable CONFIG error. - The refusal and markDead reason carry the underlying probe error, so health.lastError shows the actual timeout. - EReader.stop() is called from EClient.reset() so frames queued when a socket died are not handed to the next session's decoder. The probe can only be trusted once the timers it runs on are, so inbound dispatch is batched. Decode and dispatch of every inbound frame happened synchronously in one event-loop turn, so account-update bursts and open/completed-order sweeps starved the timers that UTA uses for request deadlines and liveness probes. The reader now extracts frames into an ordered queue and drains at most 200 per macrotask, yielding with setImmediate between batches. An exception thrown inside a decoder callback also tore the socket down; only a framing failure loses alignment, so a consumer-side handler defect is now reported with safe metadata and skipped. `managedAccounts` assigned `accountId_` before the empty-list guard, so a transient empty push -- TWS re-sends managedAccounts on FA re-login -- reset an id the session had already established, and every later `getAccountId()` returned null under a live account. It returns before touching the field. `EReader.stop()` also claimed EClient reuses one EReader across reconnects. It does not: `EClient.connect` builds a fresh one. The distinction is load-bearing because `stopped` latches and `start()` early-returns on it, so if the claim were true a reconnect would silently never resume reading. --- packages/ibkr/src/client/base.ts | 25 +- packages/ibkr/src/reader.ts | 100 ++++-- packages/ibkr/tests/reader-batching.spec.ts | 215 +++++++++++++ .../trading/brokers/ibkr/IbkrBroker.spec.ts | 111 +++++++ .../domain/trading/brokers/ibkr/IbkrBroker.ts | 90 +++++- .../brokers/ibkr/request-bridge.spec.ts | 242 +++++++++++++- .../trading/brokers/ibkr/request-bridge.ts | 304 +++++++++++++----- 7 files changed, 947 insertions(+), 140 deletions(-) create mode 100644 packages/ibkr/tests/reader-batching.spec.ts diff --git a/packages/ibkr/src/client/base.ts b/packages/ibkr/src/client/base.ts index 116980e559..0ce992b4b1 100644 --- a/packages/ibkr/src/client/base.ts +++ b/packages/ibkr/src/client/base.ts @@ -88,6 +88,9 @@ export class EClient { } reset(): void { + // A batched drain may still hold frames from this connection, and this + // instance is reused on reconnect. + this.reader?.stop() this.decoder = null this.conn = null this.host = null @@ -307,10 +310,24 @@ export class EClient { throw error } - if (frame.kind === 'protobuf') { - this.decoder.processProtoBuf(frame.payload, frame.msgId) - } else { - this.decoder.interpret(frame.msgId, readFields(frame.payload)) + try { + if (frame.kind === 'protobuf') { + this.decoder.processProtoBuf(frame.payload, frame.msgId) + } else { + this.decoder.interpret(frame.msgId, readFields(frame.payload)) + } + } catch (error) { + // Lost framing alignment is unrecoverable, so rethrow and let the reader + // tear the connection down. Anything else is a consumer-side defect on one + // already-parsed message. + if (error instanceof BadMessage) throw error + this.wrapper.error( + NO_VALID_ID, + currentTimeMillis(), + errors.BAD_MESSAGE.code(), + `Handler failure for ${frame.kind} msgId=${frame.msgId}`, + '', + ) } } diff --git a/packages/ibkr/src/reader.ts b/packages/ibkr/src/reader.ts index cf971d16ec..12ca87b666 100644 --- a/packages/ibkr/src/reader.ts +++ b/packages/ibkr/src/reader.ts @@ -3,16 +3,24 @@ * Mirrors: ibapi/reader.py * * Node.js adaptation: Python uses a background thread + queue. Here we use - * socket 'data' events → buffer accumulation → message extraction → callback. - * No threads, no queue. + * socket 'data' events → buffer accumulation → frame extraction → bounded + * drain → callback. Dispatch is batched so a large inbound burst cannot + * monopolise the event loop and starve other layers' timers. */ import { readMsg } from './comm.js' import type { Connection } from './connection.js' +/** Messages dispatched per macrotask before yielding back to the event loop. */ +export const MAX_MESSAGES_PER_TURN = 200 + export class EReader { private conn: Connection private buf: Buffer = Buffer.alloc(0) + private queue: Buffer[] = [] + private draining = false + private stopped = false + private dataListener: (() => void) | null = null private onMessage: (msg: Buffer) => void private onError?: (error: unknown) => void @@ -30,41 +38,89 @@ export class EReader { * Start listening for incoming data. */ start(): void { - this.conn.on('data', () => { + if (this.stopped || this.dataListener) return + this.dataListener = (): void => { this.processData() - }) + } + this.conn.on('data', this.dataListener) + } + + /** + * Detaches from the socket and discards everything still queued. Idempotent + * and terminal: `start()` will not re-arm a stopped reader. + */ + stop(): void { + this.stopped = true + this.draining = false + this.queue.length = 0 + this.buf = Buffer.alloc(0) + if (this.dataListener) { + this.conn.off('data', this.dataListener) + this.dataListener = null + } } /** * Process accumulated socket data, extracting complete messages. */ private processData(): void { + if (this.stopped) return + // Consume whatever has accumulated in the connection buffer const incoming = this.conn.consumeBuffer() if (incoming.length === 0) return this.buf = Buffer.concat([this.buf, incoming]) - // Extract as many complete messages as possible + // Framing must stay ordered and synchronous; only dispatch is deferred. while (this.buf.length > 0) { - const [size, msg, rest] = readMsg(this.buf) - if (msg.length > 0) { - this.buf = rest - try { - this.onMessage(msg) - } catch (error) { - // A decoder failure means field alignment is no longer trustworthy. - // Drop every buffered successor and let the client replace this - // connection instead of continuing from an uncertain boundary. - this.buf = Buffer.alloc(0) - if (!this.onError) throw error - this.onError(error) - break - } - } else { - // Incomplete message — wait for more data - break + const [, msg, rest] = readMsg(this.buf) + // Incomplete message: wait for more data + if (msg.length === 0) break + this.buf = rest + this.queue.push(msg) + } + + if (!this.draining) { + this.draining = true + this.drain() + } + } + + /** + * Dispatch queued frames in order, at most MAX_MESSAGES_PER_TURN per + * macrotask. Yielding with setImmediate lets pending timers fire between + * batches instead of after the whole burst. + */ + private drain(): void { + let dispatched = 0 + + while (this.queue.length > 0) { + if (dispatched >= MAX_MESSAGES_PER_TURN) { + setImmediate(() => { + if (this.stopped) { + this.draining = false + return + } + this.drain() + }) + return + } + + const msg = this.queue.shift()! + dispatched++ + try { + this.onMessage(msg) + } catch (error) { + // Field alignment is no longer trustworthy, so drop every buffered + // successor and let the client replace this connection. + this.stop() + if (!this.onError) throw error + this.onError(error) + return } } + + this.draining = false } } diff --git a/packages/ibkr/tests/reader-batching.spec.ts b/packages/ibkr/tests/reader-batching.spec.ts new file mode 100644 index 0000000000..2606ecdacc --- /dev/null +++ b/packages/ibkr/tests/reader-batching.spec.ts @@ -0,0 +1,215 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { EClient } from '../src/client/base.js' +import { makeField, makeMsg } from '../src/comm.js' +import { Decoder, applyAllHandlers } from '../src/decoder/index.js' +import { EReader, MAX_MESSAGES_PER_TURN } from '../src/reader.js' +import { IN } from '../src/message.js' +import type { ConnectionWrapper } from '../src/connection.js' +import { DefaultEWrapper } from '../src/wrapper.js' + +class FakeConnection extends EventEmitter { + wrapper: ConnectionWrapper | null = null + private incoming = Buffer.alloc(0) + private connected = true + + push(data: Buffer): void { + this.incoming = Buffer.concat([this.incoming, data]) + this.emit('data') + } + + consumeBuffer(): Buffer { + const data = this.incoming + this.incoming = Buffer.alloc(0) + return data + } + + isConnected(): boolean { + return this.connected + } + + disconnect(): void { + if (!this.connected) return + this.connected = false + this.wrapper?.connectionClosed() + } +} + +function currentTimeFrame(value: number): Buffer { + return makeMsg(IN.CURRENT_TIME, true, makeField(1) + makeField(value)) +} + +describe('EReader dispatch batching', () => { + it('yields to timers between batches while preserving message order', async () => { + const connection = new FakeConnection() + const trace: string[] = [] + const reader = new EReader(connection as never, (msg) => { + trace.push(`msg:${msg.readUInt32BE(0)}`) + }) + reader.start() + + const burst = MAX_MESSAGES_PER_TURN * 2 + 5 + const frames: Buffer[] = [] + for (let i = 0; i < burst; i++) { + const payload = Buffer.alloc(4) + payload.writeUInt32BE(i) + const header = Buffer.alloc(4) + header.writeUInt32BE(payload.length) + frames.push(Buffer.concat([header, payload])) + } + + const timerFired = new Promise((resolve) => { + setTimeout(() => { + trace.push('timer') + resolve() + }, 0) + }) + + connection.push(Buffer.concat(frames)) + + // The synchronous push must not have drained the whole burst. + expect(trace).toHaveLength(MAX_MESSAGES_PER_TURN) + + await timerFired + await new Promise((resolve) => setTimeout(resolve, 10)) + + const messages = trace.filter(entry => entry !== 'timer') + expect(messages).toHaveLength(burst) + expect(messages).toEqual( + Array.from({ length: burst }, (_, i) => `msg:${i}`), + ) + // A timer got a turn before the last message was dispatched. + expect(trace.indexOf('timer')).toBeLessThan(trace.length - 1) + }) +}) + +function connectedClient(wrapper: DefaultEWrapper): { + client: EClient + connection: FakeConnection + reader: EReader + } { + const client = new EClient(wrapper) + const connection = new FakeConnection() + connection.wrapper = wrapper + client.conn = connection as never + client.serverVersion_ = 206 + client.decoder = new Decoder(wrapper, 206) + applyAllHandlers(client.decoder) + client.setConnState(EClient.CONNECTED) + + const privateClient = client as unknown as { + onMessage(message: Buffer): void + handleReaderError(error: unknown): void + } + const reader = new EReader( + connection as never, + (message) => privateClient.onMessage(message), + (error) => privateClient.handleReaderError(error), + ) + client.reader = reader + reader.start() + return { client, connection, reader } +} + +describe('EReader failure classification', () => { + it('keeps the connection when a decoder callback throws and still decodes the successor', () => { + const wrapper = new DefaultEWrapper() + const error = vi.spyOn(wrapper, 'error') + const connectionClosed = vi.spyOn(wrapper, 'connectionClosed') + const currentTime = vi.spyOn(wrapper, 'currentTime') + .mockImplementationOnce(() => { throw new TypeError('consumer handler defect') }) + const { client, connection } = connectedClient(wrapper) + + expect(() => connection.push(Buffer.concat([ + currentTimeFrame(1784289600), + currentTimeFrame(1784289601), + ]))).not.toThrow() + + expect(currentTime).toHaveBeenCalledTimes(2) + expect(connectionClosed).not.toHaveBeenCalled() + expect(client.connState).toBe(EClient.CONNECTED) + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0][3]).toContain('Handler failure for text msgId=49') + expect(error.mock.calls[0][3]).not.toContain('consumer handler defect') + }) + + it('still tears the connection down on a framing failure', () => { + const wrapper = new DefaultEWrapper() + const connectionClosed = vi.spyOn(wrapper, 'connectionClosed') + const currentTime = vi.spyOn(wrapper, 'currentTime') + const { client, connection } = connectedClient(wrapper) + + const malformedAccount = makeMsg( + IN.ACCT_VALUE, + true, + makeField(2) + makeField('CashBalance') + makeField('DU_TEST'), + ) + + expect(() => connection.push(Buffer.concat([ + malformedAccount, + currentTimeFrame(1784289600), + ]))).not.toThrow() + + expect(currentTime).not.toHaveBeenCalled() + expect(connectionClosed).toHaveBeenCalledOnce() + expect(client.connState).toBe(EClient.DISCONNECTED) + }) +}) + +describe('EReader teardown', () => { + it('drops queued frames and detaches on stop, so a later session never sees them', async () => { + const connection = new FakeConnection() + const seen: number[] = [] + const reader = new EReader(connection as never, (msg) => { + seen.push(msg.readUInt32BE(0)) + }) + reader.start() + + const burst = MAX_MESSAGES_PER_TURN * 2 + const frames: Buffer[] = [] + for (let i = 0; i < burst; i++) { + const payload = Buffer.alloc(4) + payload.writeUInt32BE(i) + const header = Buffer.alloc(4) + header.writeUInt32BE(payload.length) + frames.push(Buffer.concat([header, payload])) + } + connection.push(Buffer.concat(frames)) + expect(seen).toHaveLength(MAX_MESSAGES_PER_TURN) + + // Teardown lands between drain batches. + reader.stop() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(seen).toHaveLength(MAX_MESSAGES_PER_TURN) + + // The socket listener is gone too: nothing from the dead session leaks. + connection.push(frames[0]!) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(seen).toHaveLength(MAX_MESSAGES_PER_TURN) + expect(connection.listenerCount('data')).toBe(0) + }) + + it('stops the reader when the client resets, so a reconnect cannot decode stale frames', async () => { + const wrapper = new DefaultEWrapper() + const currentTime = vi.spyOn(wrapper, 'currentTime') + const { client, connection } = connectedClient(wrapper) + + const burst: Buffer[] = [] + for (let i = 0; i < MAX_MESSAGES_PER_TURN + 5; i++) burst.push(currentTimeFrame(1_784_289_600 + i)) + connection.push(Buffer.concat(burst)) + expect(currentTime).toHaveBeenCalledTimes(MAX_MESSAGES_PER_TURN) + + // Teardown lands between drain batches, with 5 frames still queued. + client.disconnect() + + // A new session installs a fresh decoder before the old reader's next + // macrotask, and the 5 queued frames belong to a dead socket. + client.serverVersion_ = 206 + client.decoder = new Decoder(wrapper, 206) + applyAllHandlers(client.decoder) + client.setConnState(EClient.CONNECTED) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(currentTime).toHaveBeenCalledTimes(MAX_MESSAGES_PER_TURN) + }) +}) 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 19ac58ba6e..c9c4f5ddd2 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -46,10 +46,12 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { requestSnapshot: ReturnType requestCurrentTime: ReturnType getNextOrderId: ReturnType + lastInboundAt: number requestOrder: ReturnType markDead: ReturnType } client: { + isConnected: ReturnType reqContractDetails: ReturnType reqMktData: ReturnType placeOrder: ReturnType @@ -70,8 +72,10 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { orderState: { status: accepts && !accepts('Submitted') ? 'Cancelled' : 'Submitted' }, })), markDead: vi.fn(), + lastInboundAt: 0, } const client = { + isConnected: vi.fn(() => true), reqContractDetails: vi.fn(), reqMktData: vi.fn(), placeOrder: vi.fn(), @@ -678,3 +682,110 @@ describe('IbkrBroker — dead-connection gate (issue #294)', () => { expect(client.cancelOrder).toHaveBeenCalledOnce() }) }) + +describe('IbkrBroker — liveness policy', () => { + it('retries a missed write probe once before failing the write', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestCurrentTime + .mockRejectedValueOnce(new Error('currentTime request timed out after 10000ms')) + .mockResolvedValueOnce(1_784_289_600) + const { contract, order } = stkOrder() + + await expect(broker.placeOrder(contract, order)).resolves.toMatchObject({ success: true }) + + expect(bridge.requestCurrentTime).toHaveBeenCalledTimes(2) + expect(bridge.markDead).not.toHaveBeenCalled() + expect(client.placeOrder).toHaveBeenCalledOnce() + }) + + it('carries the underlying probe error into the refusal and the markDead reason', async () => { + const { broker, bridge } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestCurrentTime.mockRejectedValue( + new Error('currentTime request timed out after 10000ms'), + ) + const { contract, order } = stkOrder() + + const result = await broker.placeOrder(contract, order) + + expect(result.error).toContain('currentTime request timed out after 10000ms') + expect(bridge.markDead).toHaveBeenCalledOnce() + expect(bridge.markDead.mock.calls[0][0]).toContain('currentTime request timed out after 10000ms') + }) + + it('reports the socket verdict when the transport itself is down', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + client.isConnected.mockReturnValue(false) + bridge.requestCurrentTime.mockRejectedValue(new Error('currentTime request timed out after 10000ms')) + const { contract, order } = stkOrder() + + const result = await broker.placeOrder(contract, order) + + expect(result.error).toMatch(/socket reported not connected/) + expect(bridge.markDead.mock.calls[0][0]).toMatch(/socket reported not connected/) + }) + + it('only marks the connection dead after two consecutive heartbeat misses', async () => { + vi.useFakeTimers() + try { + const { broker, bridge } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestCurrentTime.mockRejectedValue( + new Error('currentTime request timed out after 15000ms'), + ) + ;(broker as unknown as { startHeartbeat(): void }).startHeartbeat() + + await vi.advanceTimersByTimeAsync(45_000) + expect(bridge.markDead).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(45_000) + expect(bridge.markDead).toHaveBeenCalledOnce() + expect(bridge.markDead.mock.calls[0][0]).toContain('currentTime request timed out after 15000ms') + + ;(broker as unknown as { stopHeartbeat(): void }).stopHeartbeat() + } finally { + vi.useRealTimers() + } + }) + + it('does not count a slow probe as a miss while inbound traffic continues', async () => { + vi.useFakeTimers() + try { + const { broker, bridge } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestCurrentTime.mockImplementation(() => { + bridge.lastInboundAt = Date.now() + 1 + return Promise.reject(new Error('currentTime request timed out after 15000ms')) + }) + ;(broker as unknown as { startHeartbeat(): void }).startHeartbeat() + + await vi.advanceTimersByTimeAsync(45_000 * 3) + expect(bridge.markDead).not.toHaveBeenCalled() + + // The exemption is bounded: account pushes on a half-open socket must + // not hide a silent request/reply path forever (issue #294). + await vi.advanceTimersByTimeAsync(45_000 * 2) + expect(bridge.markDead).toHaveBeenCalledOnce() + + ;(broker as unknown as { stopHeartbeat(): void }).stopHeartbeat() + } finally { + vi.useRealTimers() + } + }) + + it('resets the miss counter after a successful probe', async () => { + vi.useFakeTimers() + try { + const { broker, bridge } = brokerWithContractIo(recordedContract('aapl-stock')) + bridge.requestCurrentTime + .mockRejectedValueOnce(new Error('probe miss')) + .mockResolvedValueOnce(1_784_289_600) + .mockRejectedValueOnce(new Error('probe miss')) + ;(broker as unknown as { startHeartbeat(): void }).startHeartbeat() + + await vi.advanceTimersByTimeAsync(45_000 * 3) + expect(bridge.markDead).not.toHaveBeenCalled() + + ;(broker as unknown as { stopHeartbeat(): void }).stopHeartbeat() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 41ea5bb198..0060f911b2 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -47,7 +47,16 @@ import { resolveSymbol } from './ibkr-contracts.js' import { applyStandaloneOcaType, buildIbkrBracket, refuseBracketOcaGroup } from './ibkr-bracket.js' import type { IbkrBrokerConfig } from './ibkr-types.js' -const WRITE_LIVENESS_TIMEOUT_MS = 3_000 +// IB Gateway can defer a currentTime reply well past a second under its own +// load, so the probe is a coarse failure detector, not a latency assertion. +const WRITE_LIVENESS_TIMEOUT_MS = 10_000 +const HEARTBEAT_INTERVAL_MS = 45_000 +const HEARTBEAT_PROBE_TIMEOUT_MS = 15_000 +const HEARTBEAT_MISS_THRESHOLD = 2 +// Inbound account pushes prove the socket still delivers, not that it still +// accepts, so the exemption is bounded or a half-open socket hides forever +// (issue #294). +const HEARTBEAT_INBOUND_EXEMPTION_LIMIT = 3 const OPTION_MARK_SUCCESS_TTL_MS = 15_000 const OPTION_MARK_FAILURE_TTL_MS = 60_000 const OPTION_MARK_CONCURRENCY = 8 @@ -140,6 +149,12 @@ export class IbkrBroker implements IBroker { /** Periodic socket probe — see _ensureAlive / issue #294. */ private heartbeatTimer_: ReturnType | null = null + /** Consecutive probe misses with no inbound traffic. Reset by any success. */ + private heartbeatMisses_ = 0 + + /** Consecutive probe misses forgiven because inbound traffic continued. */ + private heartbeatExemptions_ = 0 + /** 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). */ @@ -157,13 +172,33 @@ export class IbkrBroker implements IBroker { this._ensureAlive() try { await this.bridge.requestCurrentTime(WRITE_LIVENESS_TIMEOUT_MS) + this.heartbeatMisses_ = 0 + this.heartbeatExemptions_ = 0 this._ensureAlive() - } catch (err) { - this.bridge.markDead('Write-path liveness probe failed') - throw new BrokerError( - 'NETWORK', - `TWS/Gateway write-path liveness check failed; order was not transmitted: ${err instanceof Error ? err.message : String(err)}`, - ) + return + } catch (firstErr) { + // One missed reply is not proof of a dead socket, so re-probe once and + // report the socket's own verdict alongside the probe error. + const socketHealthy = this.client.isConnected?.() ?? true + try { + await this.bridge.requestCurrentTime(WRITE_LIVENESS_TIMEOUT_MS) + this.heartbeatMisses_ = 0 + this.heartbeatExemptions_ = 0 + this._ensureAlive() + return + } catch (retryErr) { + const socketStillHealthy = socketHealthy && (this.client.isConnected?.() ?? true) + const cause = retryErr instanceof Error ? retryErr.message : String(retryErr) + const first = firstErr instanceof Error ? firstErr.message : String(firstErr) + const detail = socketStillHealthy + ? `2 consecutive misses (${first}; ${cause})` + : `socket reported not connected (${cause})` + this.bridge.markDead(`Write-path liveness probe failed: ${detail}`) + throw new BrokerError( + 'NETWORK', + `TWS/Gateway write-path liveness check failed; order was not transmitted: ${detail}`, + ) + } } } @@ -173,13 +208,36 @@ export class IbkrBroker implements IBroker { private startHeartbeat(): void { if (this.heartbeatTimer_) clearInterval(this.heartbeatTimer_) + this.heartbeatMisses_ = 0 + this.heartbeatExemptions_ = 0 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('TWS/Gateway heartbeat timed out') - }) - }, 45_000) + const startedAt = Date.now() + this.bridge.requestCurrentTime(HEARTBEAT_PROBE_TIMEOUT_MS).then( + () => { this.heartbeatMisses_ = 0; this.heartbeatExemptions_ = 0 }, + (err: unknown) => { + const cause = err instanceof Error ? err.message : String(err) + // Inbound traffic during the probe window proves the socket still + // carries data, but only until the exemptions run out. + if ((this.bridge.lastInboundAt ?? 0) > startedAt + && this.heartbeatExemptions_ < HEARTBEAT_INBOUND_EXEMPTION_LIMIT) { + this.heartbeatExemptions_++ + this.heartbeatMisses_ = 0 + console.warn(`IbkrBroker[${this.id}]: heartbeat probe missed but inbound traffic continued (${this.heartbeatExemptions_}/${HEARTBEAT_INBOUND_EXEMPTION_LIMIT}): ${cause}`) + return + } + this.heartbeatMisses_++ + if (this.heartbeatMisses_ < HEARTBEAT_MISS_THRESHOLD) { + console.warn(`IbkrBroker[${this.id}]: heartbeat miss ${this.heartbeatMisses_}/${HEARTBEAT_MISS_THRESHOLD}: ${cause}`) + return + } + console.warn(`IbkrBroker[${this.id}]: heartbeat failed ${this.heartbeatMisses_} times — marking connection dead: ${cause}`) + this.heartbeatMisses_ = 0 + this.heartbeatExemptions_ = 0 + this.bridge.markDead(`TWS/Gateway heartbeat timed out (${HEARTBEAT_MISS_THRESHOLD} consecutive misses): ${cause}`) + }, + ) + }, HEARTBEAT_INTERVAL_MS) // Don't hold the process open for the probe this.heartbeatTimer_.unref?.() } @@ -227,8 +285,14 @@ export class IbkrBroker implements IBroker { } } - async close(): Promise { + private stopHeartbeat(): void { if (this.heartbeatTimer_) { clearInterval(this.heartbeatTimer_); this.heartbeatTimer_ = null } + this.heartbeatMisses_ = 0 + this.heartbeatExemptions_ = 0 + } + + async close(): Promise { + this.stopHeartbeat() this.bridge.stopAccountSubscription() this.client.disconnect() } 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 ecf56b3acb..3fa6640f4c 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 @@ -34,6 +34,7 @@ describe('RequestBridge — connection handshake', () => { if (stage === 'start-api') { stage = 'done' socket.write(makeMsg(9, true, makeField(1) + makeField(700))) + socket.write(makeMsg(15, true, makeField(1) + makeField('DU1234567'))) } }) }) @@ -51,6 +52,7 @@ describe('RequestBridge — connection handshake', () => { .resolves.toBeUndefined() expect(client.isConnected()).toBe(true) expect(bridge.getNextOrderId()).toBe(700) + expect(bridge.getAccountId()).toBe('DU1234567') } finally { client.disconnect() await new Promise((resolve) => server.close(() => resolve())) @@ -158,20 +160,61 @@ describe('RequestBridge — error routing', () => { }) describe('RequestBridge — socket probes and snapshots', () => { - it('coalesces concurrent current-time probes onto one wire request', async () => { - const b = new RequestBridge() - const reqCurrentTime = vi.fn() - b.setClient({ reqCurrentTime } as never) + it('gives every current-time probe its own deadline instead of a shared promise', async () => { + vi.useFakeTimers() + try { + const b = new RequestBridge() + const reqCurrentTime = vi.fn() + b.setClient({ reqCurrentTime } as never) + + const slow = b.requestCurrentTime(5_000) + const slowSettled = vi.fn() + slow.then(slowSettled, slowSettled) + vi.advanceTimersByTime(4_900) + + // A short write probe started late must NOT inherit the heartbeat's + // nearly-expired deadline. + const fast = b.requestCurrentTime(3_000) + expect(reqCurrentTime).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(200) + await Promise.resolve() + await expect(slow).rejects.toThrow(/timed out after 5000ms/) + + // The reply owed to the expired heartbeat must not settle the fresh probe. + b.currentTime(1_784_289_600) + b.currentTime(1_784_289_601) + await expect(fast).resolves.toBe(1_784_289_601) + } finally { + vi.useRealTimers() + } + }) + + it('ignores a late reply when no probe is waiting for it', async () => { + vi.useFakeTimers() + try { + const b = new RequestBridge() + b.setClient({ reqCurrentTime: vi.fn() } as never) - const first = b.requestCurrentTime() - const second = b.requestCurrentTime() - expect(reqCurrentTime).toHaveBeenCalledOnce() + const probe = b.requestCurrentTime(3_000) + const settled = vi.fn() + probe.then(settled, settled) + vi.advanceTimersByTime(3_000) + await expect(probe).rejects.toThrow(/timed out after 3000ms/) - b.currentTime(1_784_289_600) - await expect(Promise.all([first, second])).resolves.toEqual([ - 1_784_289_600, - 1_784_289_600, - ]) + b.currentTime(1_784_289_600) // late reply for the dead probe + + const next = b.requestCurrentTime(3_000) + const nextSettled = vi.fn() + next.then(nextSettled, nextSettled) + await Promise.resolve() + expect(nextSettled).not.toHaveBeenCalled() + + b.currentTime(1_784_289_777) + await expect(next).resolves.toBe(1_784_289_777) + } finally { + vi.useRealTimers() + } }) it('clears a failed current-time probe so the next write can retry', async () => { @@ -411,3 +454,178 @@ describe('RequestBridge — order request status gating', () => { } }) }) +describe('RequestBridge — order sweep concurrency', () => { + function collectedOrder(orderId: number): { contract: Contract; order: { orderId: number }; orderState: { status: string } } { + const contract = new Contract() + contract.conId = orderId + return { contract, order: { orderId }, orderState: { status: 'Submitted' } } + } + + it('joins concurrent open-order sweeps onto one request and gives each caller the full batch', async () => { + const b = new RequestBridge() + const reqOpenOrders = vi.fn() + b.setClient({ reqOpenOrders } as never) + + const first = b.requestOpenOrders(5_000) + const second = b.requestOpenOrders(5_000) + expect(reqOpenOrders).toHaveBeenCalledOnce() + + const a = collectedOrder(1) + const c = collectedOrder(2) + b.openOrder(1, a.contract, a.order as never, a.orderState as never) + b.openOrder(2, c.contract, c.order as never, c.orderState as never) + b.openOrderEnd() + + const [one, two] = await Promise.all([first, second]) + expect(one).toHaveLength(2) + expect(two).toEqual(one) + }) + + it('joins concurrent completed-order sweeps the same way', async () => { + const b = new RequestBridge() + const reqCompletedOrders = vi.fn() + b.setClient({ reqCompletedOrders } as never) + + const first = b.requestCompletedOrders(5_000) + const second = b.requestCompletedOrders(5_000) + expect(reqCompletedOrders).toHaveBeenCalledOnce() + + const done = collectedOrder(3) + b.completedOrder(done.contract, done.order as never, done.orderState as never) + b.completedOrdersEnd() + + await expect(Promise.all([first, second])).resolves.toEqual([ + [expect.objectContaining({ orderState: { status: 'Submitted' } })], + [expect.objectContaining({ orderState: { status: 'Submitted' } })], + ]) + }) + + it('lets one caller time out without clobbering the sweep the others still await', async () => { + vi.useFakeTimers() + try { + const b = new RequestBridge() + const reqOpenOrders = vi.fn() + b.setClient({ reqOpenOrders } as never) + + const impatient = b.requestOpenOrders(1_000) + const impatientSettled = vi.fn() + impatient.then(impatientSettled, impatientSettled) + const patient = b.requestOpenOrders(10_000) + + vi.advanceTimersByTime(1_000) + await expect(impatient).rejects.toThrow(/Open orders request timed out after 1000ms/) + + const a = collectedOrder(1) + b.openOrder(1, a.contract, a.order as never, a.orderState as never) + b.openOrderEnd() + + await expect(patient).resolves.toHaveLength(1) + expect(reqOpenOrders).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) + +describe('RequestBridge — connect readiness', () => { + it('waits for managedAccounts as well as nextValidId', async () => { + const client = { connect: vi.fn(async () => {}), disconnect: vi.fn() } + const b = new RequestBridge() + + const connected = b.waitForConnect(client as never, '127.0.0.1', 7497, 0, 5_000) + const settled = vi.fn() + connected.then(settled, settled) + + b.nextValidId(700) + await Promise.resolve() + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + b.managedAccounts('DU1234567') + await expect(connected).resolves.toBeUndefined() + expect(b.getAccountId()).toBe('DU1234567') + }) +}) + +describe('RequestBridge — current-time credit decay', () => { + it('drops the owed-reply credit when the gateway never answers, so later probes still work', async () => { + vi.useFakeTimers() + try { + const b = new RequestBridge() + b.setClient({ reqCurrentTime: vi.fn() } as never) + + const dead = b.requestCurrentTime(3_000) + dead.catch(() => {}) + vi.advanceTimersByTime(3_000) + await expect(dead).rejects.toThrow(/timed out after 3000ms/) + + // The reply for `dead` never arrives. Past the grace window its credit + // must be released, or every probe after it starves forever. + vi.advanceTimersByTime(30_000) + + const next = b.requestCurrentTime(3_000) + b.currentTime(1_784_289_777) + await expect(next).resolves.toBe(1_784_289_777) + } finally { + vi.useRealTimers() + } + }) + + it('clears owed-reply credits when the connection comes back up', async () => { + vi.useFakeTimers() + try { + const b = new RequestBridge() + b.setClient({ reqCurrentTime: vi.fn() } as never) + + const dead = b.requestCurrentTime(3_000) + dead.catch(() => {}) + vi.advanceTimersByTime(3_000) + await expect(dead).rejects.toThrow(/timed out/) + + b.markAlive() + + const next = b.requestCurrentTime(3_000) + b.currentTime(1_784_289_888) + await expect(next).resolves.toBe(1_784_289_888) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('RequestBridge — managedAccounts re-push', () => { + it('keeps the established account id when TWS re-pushes an empty list', () => { + const b = new RequestBridge() + + b.managedAccounts('DU1') + b.managedAccounts('') + + expect(b.getAccountId()).toBe('DU1') + }) +}) + +describe('RequestBridge — connect readiness (empty account list)', () => { + it('does not treat an empty managedAccounts list as a usable session', async () => { + vi.useFakeTimers() + try { + const client = { connect: vi.fn(async () => {}), disconnect: vi.fn() } + const b = new RequestBridge() + + const connected = b.waitForConnect(client as never, '127.0.0.1', 7497, 0, 5_000) + const settled = vi.fn() + connected.then(settled, settled) + + b.nextValidId(700) + b.managedAccounts('') + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5_000) + // A retryable NETWORK timeout naming the missing reply, not a + // non-retryable CONFIG "No account detected" later in init(). + await expect(connected).rejects.toThrow(/no managedAccounts/) + } finally { + vi.useRealTimers() + } + }) +}) 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 9a12ad014b..e7ae6d3cb2 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -36,6 +36,30 @@ import type { CollectedOpenOrder, } from './ibkr-types.js' +interface OrderBatchWaiter { + resolve: (orders: CollectedOpenOrder[]) => void + reject: (err: Error) => void + timer: ReturnType +} + +interface OrderBatch { + orders: CollectedOpenOrder[] + waiters: OrderBatchWaiter[] +} + +interface CurrentTimeWaiter { + resolve: (time: number) => void + reject: (err: Error) => void + timer: ReturnType +} + +/** + * How long a reply is still assumed to belong to an already-expired currentTime + * probe. Past this window the credit is dropped, or a reply the gateway never + * sends starves every later probe. + */ +const CURRENT_TIME_REPLY_GRACE_MS = 30_000 + const DEFAULT_TIMEOUT_MS = 10_000 const SNAPSHOT_TIMEOUT_MS = 12_500 const ACCOUNT_READY_TIMEOUT_MS = 20_000 @@ -82,20 +106,12 @@ export class RequestBridge extends DefaultEWrapper { // ---- Mode B: orderId-based pending requests ---- private orderPending = new Map() - // ---- Mode C: single-slot collectors ---- - private openOrdersCollector: { - orders: CollectedOpenOrder[] - resolve: (orders: CollectedOpenOrder[]) => void - reject: (err: Error) => void - timer: ReturnType - } | null = null - - private completedOrdersCollector: { - orders: CollectedOpenOrder[] - resolve: (orders: CollectedOpenOrder[]) => void - reject: (err: Error) => void - timer: ReturnType - } | null = null + // ---- Mode C: single-flight batch collectors ---- + // One in-flight sweep per kind, joined by every concurrent caller. Each + // waiter keeps its own deadline; a waiter timing out never clobbers the + // batch that the others are still collecting. + private openOrdersBatch: OrderBatch | null = null + private completedOrdersBatch: OrderBatch | null = null // ---- Mode D: persistent account subscription cache ---- private accountCache_: AccountDownloadResult | null = null @@ -116,10 +132,19 @@ export class RequestBridge extends DefaultEWrapper { private connectResolve: (() => void) | null = null private connectReject: ((err: Error) => void) | null = null private connectTimer: ReturnType | null = null + private sawNextValidId_ = false + private sawManagedAccounts_ = false + + // ---- Current time probes (FIFO) ---- + // currentTime carries no request id, so replies are matched to probes in + // send order. Each probe owns its deadline; replies owed to already-expired + // probes are discarded instead of resolving a later probe with a stale + // timestamp. + private currentTimeWaiters: CurrentTimeWaiter[] = [] + private currentTimeExpired_: ReturnType[] = [] - // ---- Current time request ---- - private currentTimePending: PendingRequest | null = null - private currentTimePromise: Promise | null = null + // ---- Inbound activity ---- + private lastInboundAt_ = 0 private connectionStateListener: ((event: BrokerConnectionStateEvent) => void) | null = null @@ -160,6 +185,9 @@ export class RequestBridge extends DefaultEWrapper { timeoutMs = 15_000, ): Promise { this.client_ = client + this.sawNextValidId_ = false + this.sawManagedAccounts_ = false + this.clearCurrentTimeCredits() if (this.connectReject) { this.rejectConnect(new BrokerError('NETWORK', 'Previous TWS/Gateway connection attempt was superseded')) @@ -169,8 +197,12 @@ export class RequestBridge extends DefaultEWrapper { this.connectResolve = resolve this.connectReject = reject this.connectTimer = setTimeout(() => { + const missing = [ + this.sawNextValidId_ ? null : 'nextValidId', + this.sawManagedAccounts_ ? null : 'managedAccounts', + ].filter(Boolean).join(' and ') this.rejectConnect( - new BrokerError('NETWORK', `Connection to TWS/Gateway timed out after ${timeoutMs}ms`), + new BrokerError('NETWORK', `Connection to TWS/Gateway timed out after ${timeoutMs}ms (no ${missing})`), ) }, timeoutMs) }) @@ -274,30 +306,74 @@ export class RequestBridge extends DefaultEWrapper { // ---- Mode C: single-slot requests ---- - /** Request all open orders (batch collector). */ - requestOpenOrders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + /** + * Join or start an order sweep. Concurrent callers share one in-flight + * request and each receives the complete batch; a caller that gives up + * early only removes its own waiter. + */ + private joinOrderBatch( + label: string, + read: () => OrderBatch | null, + write: (batch: OrderBatch | null) => void, + send: () => void, + timeoutMs: number, + ): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.openOrdersCollector = null - reject(new BrokerError('NETWORK', `Open orders request timed out after ${timeoutMs}ms`)) - }, timeoutMs) - - this.openOrdersCollector = { orders: [], resolve, reject, timer } - this.client_!.reqOpenOrders() + const existing = read() + const batch: OrderBatch = existing ?? { orders: [], waiters: [] } + const waiter: OrderBatchWaiter = { + resolve, + reject, + timer: setTimeout(() => { + const idx = batch.waiters.indexOf(waiter) + if (idx >= 0) batch.waiters.splice(idx, 1) + // The sweep is only abandoned once nobody is left waiting for it. + if (batch.waiters.length === 0 && read() === batch) write(null) + reject(new BrokerError('NETWORK', `${label} request timed out after ${timeoutMs}ms`)) + }, timeoutMs), + } + batch.waiters.push(waiter) + if (existing) return + + write(batch) + try { + send() + } catch (err) { + write(null) + this.settleOrderBatch(batch, BrokerError.from(err, 'NETWORK')) + } }) } + private settleOrderBatch(batch: OrderBatch, error?: Error): void { + const waiters = batch.waiters.splice(0) + for (const waiter of waiters) { + clearTimeout(waiter.timer) + if (error) waiter.reject(error) + else waiter.resolve(batch.orders) + } + } + + /** Request all open orders (batch collector). */ + requestOpenOrders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return this.joinOrderBatch( + 'Open orders', + () => this.openOrdersBatch, + (batch) => { this.openOrdersBatch = batch }, + () => this.client_!.reqOpenOrders(), + timeoutMs, + ) + } + /** Request completed orders (filled/cancelled). */ requestCompletedOrders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.completedOrdersCollector = null - reject(new BrokerError('NETWORK', `Completed orders request timed out after ${timeoutMs}ms`)) - }, timeoutMs) - - this.completedOrdersCollector = { orders: [], resolve, reject, timer } - this.client_!.reqCompletedOrders(true) - }) + return this.joinOrderBatch( + 'Completed orders', + () => this.completedOrdersBatch, + (batch) => { this.completedOrdersBatch = batch }, + () => this.client_!.reqCompletedOrders(true), + timeoutMs, + ) } /** Get cached fill data from orderStatus callbacks. */ @@ -307,34 +383,36 @@ export class RequestBridge extends DefaultEWrapper { /** Request current TWS server time. */ requestCurrentTime(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { - if (this.currentTimePromise) return this.currentTimePromise - - let resolvePromise!: (value: number) => void - let rejectPromise!: (error: Error) => void - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve - rejectPromise = reject + return new Promise((resolve, reject) => { + const waiter: CurrentTimeWaiter = { + resolve, + reject, + timer: setTimeout(() => { + const idx = this.currentTimeWaiters.indexOf(waiter) + if (idx < 0) return + this.currentTimeWaiters.splice(idx, 1) + // A reply may still be in flight, so remember that it is owed and + // cannot be handed to a later probe as a fresh timestamp. + const credit = setTimeout(() => { + const at = this.currentTimeExpired_.indexOf(credit) + if (at >= 0) this.currentTimeExpired_.splice(at, 1) + }, CURRENT_TIME_REPLY_GRACE_MS) + credit.unref?.() + this.currentTimeExpired_.push(credit) + reject(new BrokerError('NETWORK', `currentTime request timed out after ${timeoutMs}ms`)) + }, timeoutMs), + } + this.currentTimeWaiters.push(waiter) + + try { + this.client_!.reqCurrentTime() + } catch (err) { + const idx = this.currentTimeWaiters.indexOf(waiter) + if (idx >= 0) this.currentTimeWaiters.splice(idx, 1) + clearTimeout(waiter.timer) + reject(BrokerError.from(err, 'NETWORK')) + } }) - this.currentTimePromise = promise - const clear = (): void => { - if (this.currentTimePromise === promise) this.currentTimePromise = null - this.currentTimePending = null - } - const timer = setTimeout(() => { - clear() - rejectPromise(new BrokerError('NETWORK', `currentTime request timed out`)) - }, timeoutMs) - this.currentTimePending = { - resolve: (value) => { clearTimeout(timer); clear(); resolvePromise(value as number) }, - reject: (error) => { clearTimeout(timer); clear(); rejectPromise(error) }, - timer, - } - try { - this.client_!.reqCurrentTime() - } catch (err) { - this.currentTimePending?.reject(BrokerError.from(err, 'NETWORK')) - } - return promise } // ---- Mode D: persistent account subscription ---- @@ -379,6 +457,21 @@ export class RequestBridge extends DefaultEWrapper { // ==================== Internal helpers ==================== + /** Drop every outstanding "a reply is still owed" credit. A connection that + * just died or just came up owes nothing. */ + private clearCurrentTimeCredits(): void { + for (const credit of this.currentTimeExpired_.splice(0)) clearTimeout(credit) + } + + /** Wall-clock (Date.now) reading of the last inbound TWS callback observed. */ + get lastInboundAt(): number { return this.lastInboundAt_ } + + /** Record inbound traffic so liveness policy can treat any reply from the + * gateway as evidence that the socket is still carrying data. */ + private noteInbound(): void { + this.lastInboundAt_ = Date.now() + } + private resolveRequest(reqId: number, value: unknown): void { const entry = this.pending.get(reqId) if (!entry) return @@ -473,23 +566,23 @@ export class RequestBridge extends DefaultEWrapper { this.accountCache_ = null this.accountCachePending_ = null - if (this.openOrdersCollector) { - clearTimeout(this.openOrdersCollector.timer) - this.openOrdersCollector.reject(error) - this.openOrdersCollector = null + if (this.openOrdersBatch) { + const batch = this.openOrdersBatch + this.openOrdersBatch = null + this.settleOrderBatch(batch, error) } - if (this.completedOrdersCollector) { - clearTimeout(this.completedOrdersCollector.timer) - this.completedOrdersCollector.reject(error) - this.completedOrdersCollector = null + if (this.completedOrdersBatch) { + const batch = this.completedOrdersBatch + this.completedOrdersBatch = null + this.settleOrderBatch(batch, error) } - if (this.currentTimePending) { - clearTimeout(this.currentTimePending.timer) - this.currentTimePending.reject(error) - this.currentTimePending = null + for (const waiter of this.currentTimeWaiters.splice(0)) { + clearTimeout(waiter.timer) + waiter.reject(error) } + this.clearCurrentTimeCredits() } // ==================== EWrapper callback overrides ==================== @@ -497,16 +590,34 @@ export class RequestBridge extends DefaultEWrapper { // ---- Connection ---- override nextValidId(orderId: number): void { + this.noteInbound() this.nextOrderId_ = orderId - // Resolve the connect promise (TWS is ready) + this.sawNextValidId_ = true + this.maybeResolveConnect() + } + + /** + * A connection is only usable once both startApi replies have landed; + * nextValidId alone leaves `getAccountId()` depending on callback ordering. + */ + private maybeResolveConnect(): void { + if (!this.sawNextValidId_ || !this.sawManagedAccounts_) return const resolve = this.connectResolve this.clearConnectWaiter() resolve?.() } override managedAccounts(accountsList: string): void { + this.noteInbound() const accounts = accountsList.split(',').map(s => s.trim()).filter(Boolean) - this.accountId_ = accounts[0] ?? null + // TWS re-pushes managedAccounts on FA re-login, so an empty push must not + // blank an id this session already has. Resolving connect on it would + // surface as a non-retryable CONFIG error from init(). + const first = accounts[0] + if (!first) return + this.accountId_ = first + this.sawManagedAccounts_ = true + this.maybeResolveConnect() } /** True once the socket is known-dead (connectionClosed or a failed @@ -524,6 +635,7 @@ export class RequestBridge extends DefaultEWrapper { markAlive(): void { const wasDead = this.connectionDead_ this.connectionDead_ = false + this.clearCurrentTimeCredits() if (wasDead) this.connectionStateListener?.({ state: 'alive' }) } @@ -695,6 +807,7 @@ export class RequestBridge extends DefaultEWrapper { } override updateAccountValue(key: string, val: string, currency: string, _accountName: string): void { + this.noteInbound() // 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 @@ -711,6 +824,7 @@ export class RequestBridge extends DefaultEWrapper { } override accountDownloadEnd(_accountName: string): void { + this.noteInbound() if (!this.accountCachePending_) return // Swap pending buffer into cache (atomic replace) @@ -733,6 +847,7 @@ export class RequestBridge extends DefaultEWrapper { // ---- Market data snapshot ---- override tickPrice(reqId: number, tickType: number, price: number, _attrib: TickAttrib): void { + this.noteInbound() const snap = this.snapshots.get(reqId) if (!snap) return @@ -789,12 +904,13 @@ export class RequestBridge extends DefaultEWrapper { // ---- Orders ---- override openOrder(orderId: number, contract: Contract, order: Order, orderState: OrderState): void { + this.noteInbound() const collected: CollectedOpenOrder = { contract, order, orderState } this.answerOrderRequest(orderId, collected) // Also collect for openOrders batch - this.openOrdersCollector?.orders.push(collected) + this.openOrdersBatch?.orders.push(collected) } override orderStatus( @@ -827,29 +943,39 @@ export class RequestBridge extends DefaultEWrapper { } override openOrderEnd(): void { - if (!this.openOrdersCollector) return - clearTimeout(this.openOrdersCollector.timer) - this.openOrdersCollector.resolve(this.openOrdersCollector.orders) - this.openOrdersCollector = null + const batch = this.openOrdersBatch + if (!batch) return + this.openOrdersBatch = null + this.settleOrderBatch(batch) } // ---- Completed orders ---- override completedOrder(contract: Contract, order: Order, orderState: OrderState): void { - this.completedOrdersCollector?.orders.push({ contract, order, orderState }) + this.completedOrdersBatch?.orders.push({ contract, order, orderState }) } override completedOrdersEnd(): void { - if (!this.completedOrdersCollector) return - clearTimeout(this.completedOrdersCollector.timer) - this.completedOrdersCollector.resolve(this.completedOrdersCollector.orders) - this.completedOrdersCollector = null + const batch = this.completedOrdersBatch + if (!batch) return + this.completedOrdersBatch = null + this.settleOrderBatch(batch) } // ---- Current time ---- override currentTime(time: number): void { - if (!this.currentTimePending) return - this.currentTimePending.resolve(time) + this.noteInbound() + // Handing a reply owed to an abandoned probe to the next one would report + // a stale timestamp as fresh liveness evidence. + const credit = this.currentTimeExpired_.shift() + if (credit) { + clearTimeout(credit) + return + } + const waiter = this.currentTimeWaiters.shift() + if (!waiter) return + clearTimeout(waiter.timer) + waiter.resolve(time) } } From be09c64df2f8b8bfeb4a7c8e6de28d00eadd4735 Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:01:19 -0700 Subject: [PATCH 4/9] feat(uta): IBKR OCA groups, modify guards, and historical bars OCA groups. placeOrder accepts an explicit ocaType (CLI flags arrive as strings and are coerced; the place-order route carries the field), and a group left at type 0, which TWS silently ignores, becomes CANCEL_WITH_BLOCK. ocaGroup/ocaType/parentId ride through the stage-modify-order route and show in the agent-facing order rows, which previously displayed a grouped stop as standalone. Brokers with no OCA primitive (Alpaca, CCXT, Longbridge, LeverUp) loud-refuse these fields before the write instead of dropping them. TRAIL LIMIT is declared among IBKR's supported order types. The placeOrder tool states that ocaGroup is mutually exclusive with takeProfit/stopLoss, since a bracket mints its own group for its exit legs. `parseOrderLinkId` exists so a malformed parentId is refused rather than coerced, but `parseInt` stops at the first non-digit: '12abc' parsed to 12 and '1.9' to 1. Both passed the finite/integer check and were sent to the venue as an order id the caller never named -- the silent link loss the function was written to remove. Modify guards. Verified live: IBKR rejects an OCA or parent revision on a working order with error 10327 when ocaType is sent, and ACCEPTS but ignores it otherwise, so the ledger reported a linked stop that was independent at the venue. IbkrBroker.modifyOrder now refuses ocaGroup/ocaType/parentId before touching the venue and compares every other requested change against the openOrder echo, reporting fields the venue ignored. The tool description states the recipe: place the new leg with the group, then cancel and re-place the old protective order. StageModifyOrderParams documents the same refusal so the field docs match refuseOcaRevision. Historical bars. ibkr-historical.ts owns the reqHistoricalData mapping as pure functions: a BarParams window becomes endDateTime + durationStr, all eight BarIntervals map to a native bar size, over-long spans clamp to the per-size ceiling, and the response is re-bounded locally. Error 162 is split into empty window, pacing (transient NETWORK, retried), and real failure. Negative bar volume is treated as unset. Capability quality is 'subscription'. Bar sessions. BarParams.session ('regular' | 'extended') replaces a per-call useRTH boolean and is resolved per instrument by resolveBarSession: stocks and options default to regular hours, futures and CFDs to continuous with an explicit regular honoured, FX and crypto are forced continuous. Brokers declare historicalBars.sessions; an unsupported session falls back to the continuous tape when the broker declares it, otherwise to the first session it does declare, still marked forced, instead of assuming 'extended' and handing a regular-only broker the one session it said it cannot serve. The result rides back as { bars, session, forced } through the UTA route and the Alice SDK; BarService stamps meta.session / meta.sessionForced, and marketSnapshot accepts session and reports the effective one. The /api/bars route parses the session query, rejects any other value with 400, and mirrors the meta fields plus the query parameter on the UI client and demo handler, so the chart can ask for regular or extended bars. --- docs/market-data-architecture.md | 8 +- packages/uta-protocol/src/types/broker.ts | 31 ++ packages/uta-protocol/src/types/git.ts | 20 + .../uta/src/domain/trading/OrderHelper.ts | 2 + .../trading/UnifiedTradingAccount.spec.ts | 111 ++++++ .../domain/trading/UnifiedTradingAccount.ts | 27 +- .../src/domain/trading/bar-session.spec.ts | 93 +++++ .../uta/src/domain/trading/bar-session.ts | 64 ++++ .../brokers/alpaca/AlpacaBroker.spec.ts | 49 +++ .../trading/brokers/alpaca/AlpacaBroker.ts | 6 + .../domain/trading/brokers/ccxt/CcxtBroker.ts | 3 + .../trading/brokers/ibkr/IbkrBroker.spec.ts | 175 +++++++++ .../domain/trading/brokers/ibkr/IbkrBroker.ts | 175 ++++++++- .../src/domain/trading/brokers/ibkr/README.md | 50 +++ .../trading/brokers/ibkr/ibkr-bracket.ts | 11 +- .../brokers/ibkr/ibkr-historical.spec.ts | 341 ++++++++++++++++++ .../trading/brokers/ibkr/ibkr-historical.ts | 226 ++++++++++++ .../trading/brokers/ibkr/request-bridge.ts | 24 ++ .../brokers/longbridge/LongbridgeBroker.ts | 3 + .../brokers/others/leverup/LeverupBroker.ts | 2 + services/uta/src/domain/trading/oca.ts | 91 +++++ .../http/routes-trading-historical.spec.ts | 63 ++++ services/uta/src/http/routes-trading.ts | 7 +- src/domain/analysis/snapshot.ts | 10 + .../market-data/bars/bar-service.spec.ts | 54 ++- src/domain/market-data/bars/bar-service.ts | 7 +- src/domain/market-data/bars/types.ts | 13 +- src/services/uta-client/UTAAccountSDK.ts | 17 +- src/tool/snapshot.ts | 4 +- src/tool/trading-compact.ts | 7 + src/tool/trading.spec.ts | 11 + src/tool/trading.ts | 20 +- src/webui/routes/bars.spec.ts | 31 ++ src/webui/routes/bars.ts | 10 +- ui/src/api/market.ts | 9 + ui/src/api/types.ts | 2 + ui/src/demo/handlers/market.ts | 2 + 37 files changed, 1745 insertions(+), 34 deletions(-) create mode 100644 services/uta/src/domain/trading/bar-session.spec.ts create mode 100644 services/uta/src/domain/trading/bar-session.ts create mode 100644 services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.spec.ts create mode 100644 services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.ts create mode 100644 services/uta/src/domain/trading/oca.ts create mode 100644 services/uta/src/http/routes-trading-historical.spec.ts diff --git a/docs/market-data-architecture.md b/docs/market-data-architecture.md index 7e161593ab..f94d062abd 100644 --- a/docs/market-data-architecture.md +++ b/docs/market-data-architecture.md @@ -86,7 +86,13 @@ BarService federates: - vendor K-lines from the embedded provider adapters; - broker/exchange K-lines exposed through UTA; -- source metadata such as capability and freshness. +- source metadata such as capability and freshness; +- the trading SESSION a broker series covers. + +Session is resolved by UTA per instrument (stocks/options default to regular +hours, FX/futures/crypto to the continuous tape), never by the caller, and the +effective value plus a `sessionForced` flag ride back on `BarMeta`. See the +IBKR adapter README for the default table and the broker capability fallback. UTA source discovery and Broker Pack installation are independent. `asVendor` controls whether a configured UTA joins default K-line/contract discovery; diff --git a/packages/uta-protocol/src/types/broker.ts b/packages/uta-protocol/src/types/broker.ts index ee6b7b1bad..b339c257a4 100644 --- a/packages/uta-protocol/src/types/broker.ts +++ b/packages/uta-protocol/src/types/broker.ts @@ -333,6 +333,12 @@ export type BarInterval = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d' | '1w */ export type BarWhatToShow = 'TRADES' | 'MIDPOINT' | 'BID' | 'ASK' +/** + * Which trading session a bar series covers: 'regular' is the instrument's + * regular trading hours, 'extended' the continuous tape including overnight. + */ +export type BarSession = 'regular' | 'extended' + export interface BarParams { /** Normalized interval; the broker maps it to its native bar size. */ interval: BarInterval @@ -344,6 +350,11 @@ export interface BarParams { limit?: number /** Price-stream selector — only IBKR honors non-TRADES values; others ignore. */ whatToShow?: BarWhatToShow + /** + * Which trading session the bars should cover. Omit to take the + * per-instrument default from `resolveBarSession`. + */ + session?: BarSession } /** @@ -363,6 +374,18 @@ export interface Bar { volume: string } +/** + * A UTA-level historical-bar read. The session served is part of the answer, + * because regular-session and continuous bars are different series. + */ +export interface HistoricalBarsResult { + bars: Bar[] + /** The session these bars actually cover. */ + session: BarSession + /** True when the requested session could not be honored. */ + forced: boolean +} + // ==================== Broker health ==================== export type BrokerHealth = 'healthy' | 'degraded' | 'offline' @@ -440,6 +463,11 @@ export interface HistoricalBarsCapability { quality?: 'realtime' | 'iex' | 'delayed' | 'subscription' /** Subset of BarInterval this broker actually maps to a native bar size. */ supportedBarSizes?: BarInterval[] + /** + * Session filters this broker can honor. Absent means no filter at all, and + * UTA reports such a read as a forced 'extended'. + */ + sessions?: BarSession[] } export interface AccountCapabilities { @@ -591,6 +619,9 @@ export interface IBroker { * capability; `UnifiedTradingAccount.getHistorical` loud-refuses with a * `BrokerError('CONFIG', ...)` rather than silently returning `[]`. Each * implementation maps `BarParams.interval` to its native bar size. + * + * `params.session` arrives already resolved; an adapter never applies its own + * default and reads undefined as 'extended'. */ getHistorical?(contract: Contract, params: BarParams): Promise diff --git a/packages/uta-protocol/src/types/git.ts b/packages/uta-protocol/src/types/git.ts index 8f8df43ca1..de10cf312a 100644 --- a/packages/uta-protocol/src/types/git.ts +++ b/packages/uta-protocol/src/types/git.ts @@ -277,6 +277,12 @@ export interface StagePlaceOrderParams { outsideRth?: boolean parentId?: string ocaGroup?: string + /** + * IBKR OCA semantics for `ocaGroup`: 1 CANCEL_WITH_BLOCK, 2 REDUCE_WITH_BLOCK, + * 3 REDUCE_NON_BLOCK. Omit to take the broker default, since TWS ignores a + * group whose type is 0. + */ + ocaType?: number takeProfit?: { price: string } stopLoss?: { price: string; limitPrice?: string } } @@ -291,6 +297,20 @@ export interface StageModifyOrderParams { orderType?: string tif?: string goodTillDate?: string + /** + * One-Cancels-All group name. Not revisable on a working order at IBKR, so + * link a resting order by placing the new leg with the group and re-placing + * the old one. + */ + ocaGroup?: string + /** + * OCA semantics for `ocaGroup`, same meanings as + * `StagePlaceOrderParams.ocaType` (1 CANCEL_WITH_BLOCK, + * 2 REDUCE_WITH_BLOCK, 3 REDUCE_NON_BLOCK). Same revision rule as `ocaGroup`. + */ + ocaType?: number + /** Re-parent the order (bracket attachment). Same revision rule as `ocaGroup`. */ + parentId?: string | number } export interface StageClosePositionParams { diff --git a/services/uta/src/domain/trading/OrderHelper.ts b/services/uta/src/domain/trading/OrderHelper.ts index d84087d73d..df2ebc929f 100644 --- a/services/uta/src/domain/trading/OrderHelper.ts +++ b/services/uta/src/domain/trading/OrderHelper.ts @@ -51,6 +51,7 @@ export interface OrderView { outsideRth?: boolean parentId?: number ocaGroup?: string + ocaType?: number goodTillDate?: string } @@ -105,6 +106,7 @@ export const OrderHelper = { outsideRth: order.outsideRth || undefined, parentId: order.parentId || undefined, ocaGroup: order.ocaGroup || undefined, + ocaType: order.ocaType || undefined, goodTillDate: order.goodTillDate || undefined, } }, diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts index 818be11c16..ee807164f2 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -724,6 +724,53 @@ describe('UTA — stageModifyOrder', () => { expect(op.changes.tif).toBe('GTC') }) + it('copies OCA / bracket linkage into changes so it round-trips to the broker', () => { + uta.stageModifyOrder({ orderId: 'ord-1', ocaGroup: 'aapl-exit', ocaType: 2, parentId: '17' }) + const op = uta.status().staged[0] as Extract + expect(op.changes.ocaGroup).toBe('aapl-exit') + expect(op.changes.ocaType).toBe(2) + expect(op.changes.parentId).toBe(17) + }) + + it('rejects an ocaType TWS would silently ignore', () => { + expect(() => uta.stageModifyOrder({ orderId: 'ord-1', ocaGroup: 'g', ocaType: 0 })) + .toThrow(/ocaType must be 1/) + expect(() => uta.stagePlaceOrder({ + aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1', ocaGroup: 'g', ocaType: 9, + })).toThrow(/ocaType must be 1/) + }) + + it('refuses a malformed parentId on both staging paths instead of coercing it to 0', () => { + expect(() => uta.stageModifyOrder({ orderId: 'ord-1', parentId: 'abc' })) + .toThrow(/parentId must be a numeric order id/) + expect(() => uta.stagePlaceOrder({ + aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1', parentId: 'abc', + })).toThrow(/parentId must be a numeric order id/) + }) + + it('refuses a partly numeric parentId and accepts a whole one', () => { + for (const bad of ['12abc', '1.9']) { + expect(() => uta.stageModifyOrder({ orderId: 'ord-1', parentId: bad })) + .toThrow(/parentId must be a numeric order id/) + } + for (const good of ['42', ' 42 ']) { + uta.stageModifyOrder({ orderId: 'ord-1', parentId: good }) + const staged = uta.status().staged + const op = staged[staged.length - 1] as Extract + expect(op.changes.parentId).toBe(42) + } + }) + + it('carries an explicit ocaType through stagePlaceOrder instead of forcing 1', () => { + uta.stagePlaceOrder({ + aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1', + ocaGroup: 'aapl-exit', ocaType: 3, + }) + const { order } = getStagedPlaceOrder(uta) + expect(order.ocaGroup).toBe('aapl-exit') + expect(order.ocaType).toBe(3) + }) + it('omits fields not provided', () => { uta.stageModifyOrder({ orderId: 'ord-1', lmtPrice: '160' }) const staged = uta.status().staged @@ -1685,3 +1732,67 @@ describe('UTA — connecting gate (non-blocking cold start)', () => { await expect(uta.getAccount()).resolves.toBeDefined() }) }) + +// ==================== Historical bars: session resolution ==================== + +describe('UTA — getHistorical session', () => { + /** MockBroker declares no session filter, so pin a filtering capability when + * the test is about instrument policy rather than the fallback. */ + function filteringBroker(): MockBroker { + const b = new MockBroker() + vi.spyOn(b, 'getCapabilities').mockReturnValue({ + supportedSecTypes: ['STK', 'CASH', 'FUT'], + supportedOrderTypes: ['MKT'], + historicalBars: { supported: true, quality: 'realtime', sessions: ['regular', 'extended'] }, + }) + return b + } + + const stk = () => makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', secType: 'STK' }) + + it('returns the bars alongside the effective session and forced flag', async () => { + const { uta } = createUTA(filteringBroker()) + const res = await uta.getHistorical(stk(), { interval: '1d', limit: 3 }) + expect(res.bars).toHaveLength(3) + expect(res).toMatchObject({ session: 'regular', forced: false }) + }) + + it('forwards the RESOLVED session to the broker, not the caller request', async () => { + const broker = filteringBroker() + const { uta } = createUTA(broker) + await uta.getHistorical(stk(), { interval: '1d', limit: 1 }) + const params = broker.calls('getHistorical')[0]!.args[1] as { session?: string } + expect(params.session).toBe('regular') + }) + + it('honors an explicit extended request on a stock', async () => { + const broker = filteringBroker() + const { uta } = createUTA(broker) + const res = await uta.getHistorical(stk(), { interval: '1d', limit: 1, session: 'extended' }) + expect(res).toMatchObject({ session: 'extended', forced: false }) + }) + + it('forces FX to the continuous tape even when regular hours are requested', async () => { + const broker = filteringBroker() + const { uta } = createUTA(broker) + const fx = makeContract({ aliceId: 'mock-paper|EURUSD', symbol: 'EUR', secType: 'CASH' }) + const res = await uta.getHistorical(fx, { interval: '1h', limit: 1, session: 'regular' }) + expect(res).toMatchObject({ session: 'extended', forced: true }) + const params = broker.calls('getHistorical')[0]!.args[1] as { session?: string } + expect(params.session).toBe('extended') + }) + + it('marks the read forced when the broker cannot filter sessions at all', async () => { + // Plain MockBroker declares no `historicalBars.sessions`. + const { uta } = createUTA() + const res = await uta.getHistorical(stk(), { interval: '1d', limit: 1 }) + expect(res).toMatchObject({ session: 'extended', forced: true }) + }) + + it('loud-refuses when the broker has no historical support at all', async () => { + const broker = new MockBroker() + ;(broker as unknown as { getHistorical?: unknown }).getHistorical = undefined + const { uta } = createUTA(broker) + await expect(uta.getHistorical(stk(), { interval: '1d' })).rejects.toThrow(/does not support historical bars/) + }) +}) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index bc0e157d2f..cb4e218c3b 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -15,8 +15,10 @@ const REACH_RANK: Record = { down: 0, connected: 1, readable: import { TradingGit } from './git/TradingGit.js' import { recomputeCostBasisFromCommits } from './cost-basis.js' import { projectOrderHistory, projectTradeHistory } from './order-history.js' -import type { OrderHistoryEntry, TradeHistoryEntry } from '@traderalice/uta-protocol' +import type { OrderHistoryEntry, TradeHistoryEntry, HistoricalBarsResult } from '@traderalice/uta-protocol' import { pnlOf } from './position-math.js' +import { resolveBarSession } from './bar-session.js' +import { assertOcaType, parseOrderLinkId } from './oca.js' import type { Operation, AddResult, @@ -771,8 +773,9 @@ export class UnifiedTradingAccount { if (params.trailingPercent != null) order.trailingPercent = new Decimal(String(params.trailingPercent)) if (params.goodTillDate != null) order.goodTillDate = params.goodTillDate if (params.outsideRth) order.outsideRth = true - if (params.parentId != null) order.parentId = parseInt(params.parentId, 10) || 0 + if (params.parentId != null) order.parentId = parseOrderLinkId(params.parentId, 'placeOrder') if (params.ocaGroup != null) order.ocaGroup = params.ocaGroup + if (params.ocaType != null) order.ocaType = assertOcaType(params.ocaType) const tpsl: TpSlParams | undefined = (params.takeProfit || params.stopLoss) @@ -793,6 +796,13 @@ export class UnifiedTradingAccount { if (params.orderType != null) changes.orderType = params.orderType if (params.tif != null) changes.tif = params.tif if (params.goodTillDate != null) changes.goodTillDate = params.goodTillDate + // Plain scalars, so they survive the toWire → commit.json → rehydrate + // round-trip unchanged. + if (params.ocaGroup != null) changes.ocaGroup = params.ocaGroup + if (params.ocaType != null) changes.ocaType = assertOcaType(params.ocaType) + if (params.parentId != null) { + changes.parentId = parseOrderLinkId(params.parentId, 'modifyOrder') + } return this.git.add({ action: 'modifyOrder', orderId: params.orderId, changes }) } @@ -1199,13 +1209,22 @@ export class UnifiedTradingAccount { * the broker has no `getHistorical`. Expands an aliceId-only stub to a * trade-ready contract first, same as getQuote. Bars carry no contract, so * there is no return-side aliceId stamping — the caller already holds it. + * + * The session is resolved here rather than in the adapter, and the result + * carries the effective `session` plus `forced`. */ - async getHistorical(contract: Contract, params: BarParams): Promise { + async getHistorical(contract: Contract, params: BarParams): Promise { if (typeof this.broker.getHistorical !== 'function') { throw new BrokerError('CONFIG', `Account "${this.label}" does not support historical bars.`) } const resolved = this._expandAliceIdIfNeeded(contract) - return this._callBroker(() => this.broker.getHistorical!(resolved, params)) + const { session, forced } = resolveBarSession( + resolved.secType, + params.session, + this.getCapabilities().historicalBars, + ) + const bars = await this._callBroker(() => this.broker.getHistorical!(resolved, { ...params, session })) + return { bars, session, forced } } getMarketClock(): Promise { diff --git a/services/uta/src/domain/trading/bar-session.spec.ts b/services/uta/src/domain/trading/bar-session.spec.ts new file mode 100644 index 0000000000..e62240d53b --- /dev/null +++ b/services/uta/src/domain/trading/bar-session.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import type { HistoricalBarsCapability } from '@traderalice/uta-protocol' +import { resolveBarSession } from './bar-session.js' + +/** A broker that can actually filter (IBKR). */ +const FILTERING: HistoricalBarsCapability = { supported: true, sessions: ['regular', 'extended'] } +/** A broker with no session filter (Alpaca / CCXT). */ +const UNFILTERED: HistoricalBarsCapability = { supported: true } + +describe('resolveBarSession — instrument defaults', () => { + it('defaults stock-like instruments to regular hours', () => { + for (const secType of ['STK', 'OPT', 'WAR', 'BOND', 'IND', 'FUND']) { + expect(resolveBarSession(secType, undefined, FILTERING)).toEqual({ session: 'regular', forced: false }) + } + }) + + it('defaults an unknown or missing secType to regular hours', () => { + expect(resolveBarSession(undefined, undefined, FILTERING)).toEqual({ session: 'regular', forced: false }) + expect(resolveBarSession('', undefined, FILTERING)).toEqual({ session: 'regular', forced: false }) + expect(resolveBarSession('BAG', undefined, FILTERING)).toEqual({ session: 'regular', forced: false }) + }) + + it('defaults nearly-around-the-clock instruments to the continuous tape', () => { + for (const secType of ['FUT', 'FOP', 'CFD', 'CMDTY']) { + expect(resolveBarSession(secType, undefined, FILTERING)).toEqual({ session: 'extended', forced: false }) + } + }) + + it('defaults FX and crypto to the continuous tape', () => { + expect(resolveBarSession('CASH', undefined, FILTERING)).toEqual({ session: 'extended', forced: false }) + expect(resolveBarSession('CRYPTO', undefined, FILTERING)).toEqual({ session: 'extended', forced: false }) + }) + + it('matches secType case-insensitively and ignores surrounding whitespace', () => { + expect(resolveBarSession(' cash ', 'regular', FILTERING)).toEqual({ session: 'extended', forced: true }) + expect(resolveBarSession('fut', undefined, FILTERING)).toEqual({ session: 'extended', forced: false }) + }) +}) + +describe('resolveBarSession — explicit requests', () => { + it('honors an explicit extended request on a stock', () => { + expect(resolveBarSession('STK', 'extended', FILTERING)).toEqual({ session: 'extended', forced: false }) + }) + + it('honors an explicit regular request on a future', () => { + expect(resolveBarSession('FUT', 'regular', FILTERING)).toEqual({ session: 'regular', forced: false }) + }) + + it('honors an explicit regular request on a stock (same as its default)', () => { + expect(resolveBarSession('STK', 'regular', FILTERING)).toEqual({ session: 'regular', forced: false }) + }) + + it('forces FX and crypto to extended even when regular is requested', () => { + expect(resolveBarSession('CASH', 'regular', FILTERING)).toEqual({ session: 'extended', forced: true }) + expect(resolveBarSession('CRYPTO', 'regular', FILTERING)).toEqual({ session: 'extended', forced: true }) + }) +}) + +describe('resolveBarSession — broker capability fallback', () => { + it('falls back to a session the broker declares when the resolved one is unsupported', () => { + const regularOnly: HistoricalBarsCapability = { supported: true, sessions: ['regular'] } + expect(resolveBarSession('STK', 'extended', regularOnly)).toEqual({ session: 'regular', forced: true }) + expect(resolveBarSession('CASH', undefined, regularOnly)).toEqual({ session: 'regular', forced: true }) + const extendedOnly: HistoricalBarsCapability = { supported: true, sessions: ['extended'] } + expect(resolveBarSession('STK', undefined, extendedOnly)).toEqual({ session: 'extended', forced: true }) + }) + + it('forces extended when the broker declares no session filter', () => { + expect(resolveBarSession('STK', undefined, UNFILTERED)).toEqual({ session: 'extended', forced: true }) + expect(resolveBarSession('STK', 'regular', UNFILTERED)).toEqual({ session: 'extended', forced: true }) + }) + + it('does not mark extended as forced on an unfiltered broker when extended was already right', () => { + expect(resolveBarSession('CRYPTO', undefined, UNFILTERED)).toEqual({ session: 'extended', forced: false }) + expect(resolveBarSession('STK', 'extended', UNFILTERED)).toEqual({ session: 'extended', forced: false }) + }) + + it('forces extended when the broker lists sessions but not the resolved one', () => { + const extendedOnly: HistoricalBarsCapability = { supported: true, sessions: ['extended'] } + expect(resolveBarSession('STK', undefined, extendedOnly)).toEqual({ session: 'extended', forced: true }) + }) + + it('treats a missing capability like a broker with no filter', () => { + expect(resolveBarSession('STK', undefined, undefined)).toEqual({ session: 'extended', forced: true }) + expect(resolveBarSession('CASH', undefined, undefined)).toEqual({ session: 'extended', forced: false }) + }) + + it('keeps the forced flag from the instrument rule when the broker can serve the result', () => { + // The broker can serve extended, so the capability step must not clear the + // instrument-forced flag. + expect(resolveBarSession('CASH', 'regular', UNFILTERED)).toEqual({ session: 'extended', forced: true }) + }) +}) diff --git a/services/uta/src/domain/trading/bar-session.ts b/services/uta/src/domain/trading/bar-session.ts new file mode 100644 index 0000000000..cea76b206d --- /dev/null +++ b/services/uta/src/domain/trading/bar-session.ts @@ -0,0 +1,64 @@ +/** + * Which trading session a historical-bar request should cover. Whether an + * instrument has a regular session at all is a property of the instrument, not + * a caller preference, so the policy lives here rather than in each adapter. + */ + +import type { BarSession, HistoricalBarsCapability } from '@traderalice/uta-protocol' + +/** Instruments with no regular session; the continuous tape is the only tape. */ +const ALWAYS_CONTINUOUS = new Set(['CASH', 'CRYPTO']) + +/** + * Instruments that trade nearly around the clock but do have a defined regular + * session: continuous by default, `regular` on request. + */ +const CONTINUOUS_BY_DEFAULT = new Set(['FUT', 'FOP', 'CFD', 'CMDTY']) + +export interface ResolvedBarSession { + /** The session the broker will actually be asked for. */ + session: BarSession + /** True when the resolved session is not what the caller asked for. */ + forced: boolean +} + +/** + * Resolves the effective bar session. Instrument policy is applied before + * broker capability, because an FX `regular` request is wrong at any broker. + */ +export function resolveBarSession( + secType: string | undefined, + requested: BarSession | undefined, + capability: HistoricalBarsCapability | undefined, +): ResolvedBarSession { + const kind = (secType ?? '').trim().toUpperCase() + + let session: BarSession + let forced: boolean + if (ALWAYS_CONTINUOUS.has(kind)) { + // Even an explicit 'regular' is overridden: there is no regular session. + session = 'extended' + forced = requested === 'regular' + } else if (CONTINUOUS_BY_DEFAULT.has(kind)) { + session = requested ?? 'extended' + forced = false + } else { + // A regular session is the safe default: a thin overnight tape distorts + // every level. + session = requested ?? 'regular' + forced = false + } + + // A broker can only honor the sessions it declares, so fall back to the + // continuous tape when it offers one and to whatever it does offer otherwise. + const supported = capability?.sessions + if (supported && !supported.includes(session)) { + const fallback = supported.includes('extended') ? 'extended' : supported[0] + return { session: fallback ?? 'extended', forced: true } + } + if (!supported && session === 'regular') { + return { session: 'extended', forced: true } + } + + return { session, forced } +} 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 1716e11b9c..63056775c8 100644 --- a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts @@ -944,3 +944,52 @@ describe('AlpacaBroker — getHistorical() feed handling', () => { expect(getBarsV2).toHaveBeenCalledTimes(1) }) }) + +// ==================== OCA loud-refusal ==================== + +describe('AlpacaBroker — OCA / bracket linkage refusal', () => { + beforeEach(() => { vi.clearAllMocks() }) + + function contract(): Contract { + const c = new Contract() + c.symbol = 'AAPL' + c.secType = 'STK' + return c + } + + function limitBuy(): Order { + const o = new Order() + o.action = 'BUY' + o.orderType = 'LMT' + o.totalQuantity = new Decimal(1) + o.lmtPrice = new Decimal(100) + return o + } + + // Alpaca has no OCA primitive, so dropping the field would place an unlinked + // order while the ledger believes the exits are linked. + it('refuses placeOrder carrying an ocaGroup instead of dropping it', async () => { + const acc = new AlpacaBroker({ apiKey: 'key', secretKey: 'secret', paper: true }) + const order = limitBuy() + order.ocaGroup = 'aapl-exit' + await expect(acc.placeOrder(contract(), order)).rejects.toThrow(/One-Cancels-All/) + }) + + it('refuses modifyOrder carrying ocaGroup / ocaType / parentId', async () => { + const acc = new AlpacaBroker({ apiKey: 'key', secretKey: 'secret', paper: true }) + await expect(acc.modifyOrder('1', { ocaGroup: 'aapl-exit' } as Partial)).rejects.toThrow(/One-Cancels-All/) + await expect(acc.modifyOrder('1', { ocaType: 1 } as Partial)).rejects.toThrow(/One-Cancels-All/) + await expect(acc.modifyOrder('1', { parentId: 17 } as Partial)).rejects.toThrow(/One-Cancels-All/) + }) + + it('leaves an ordinary modification unaffected', async () => { + const acc = new AlpacaBroker({ apiKey: 'key', secretKey: 'secret', paper: true }) + const { default: Alpaca } = await import('@alpacahq/alpaca-trade-api') + const replaceOrder = vi.fn().mockResolvedValue({ id: '1', status: 'new' }) + ;(acc as unknown as { client: unknown }).client = { replaceOrder } + void Alpaca + const result = await acc.modifyOrder('1', { lmtPrice: new Decimal('101') }) + expect(result.success).toBe(true) + expect(replaceOrder).toHaveBeenCalledWith('1', { limit_price: '101' }) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index 1921ca4e2d..93edcecf2b 100644 --- a/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/services/uta/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -27,6 +27,7 @@ import { type Bar, type BarParams, } from '../types.js' +import { refuseOcaLinkage } from '../../oca.js' import '../../contract-ext.js' import type { AlpacaBrokerConfig, @@ -271,6 +272,7 @@ export class AlpacaBroker implements IBroker { // ---- Trading operations ---- async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise { + refuseOcaLinkage('Alpaca', order) const symbol = resolveSymbol(contract) if (!symbol) { return { success: false, error: 'Cannot resolve contract to Alpaca symbol' } @@ -347,6 +349,7 @@ export class AlpacaBroker implements IBroker { } async modifyOrder(orderId: string, changes: Partial): Promise { + refuseOcaLinkage('Alpaca', changes) try { const patch: Record = {} if (changes.totalQuantity != null && !changes.totalQuantity.equals(UNSET_DECIMAL)) patch.qty = changes.totalQuantity.toFixed() @@ -535,6 +538,9 @@ export class AlpacaBroker implements IBroker { * capability quality 'iex'; full SIP needs a paid data subscription. */ async getHistorical(contract: Contract, params: BarParams): Promise { + // `params.session` is ignored: getBarsV2 has no RTH filter, so the feed is + // always the continuous tape (the capability declares no `sessions`, and + // UTA marks such a read forced). const symbol = resolveSymbol(contract) if (!symbol) throw new BrokerError('EXCHANGE', 'Cannot resolve contract to Alpaca symbol') const timeframe = ALPACA_TIMEFRAME[params.interval] diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 7fd8ed8773..08b1e60b56 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -28,6 +28,7 @@ import { type BarParams, type SubAccountRef, } from '../types.js' +import { refuseOcaLinkage } from '../../oca.js' import '../../contract-ext.js' import { buildPosition } from '../contract-builder.js' import { CCXT_CREDENTIAL_FIELDS, type CcxtBrokerConfig, type CcxtMarket, type FundingRate, type OrderBook, type OrderBookLevel } from './ccxt-types.js' @@ -508,6 +509,7 @@ export class CcxtBroker implements IBroker { async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams, extraParams?: Record): Promise { this.ensureInit() + refuseOcaLinkage(this.exchangeName, order) const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) @@ -646,6 +648,7 @@ export class CcxtBroker implements IBroker { async modifyOrder(orderId: string, changes: Partial): Promise { this.ensureInit() + refuseOcaLinkage(this.exchangeName, changes) try { const ccxtSymbol = this.orderSymbolCache.get(orderId) 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 c9c4f5ddd2..6bc5144fe6 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -789,3 +789,178 @@ describe('IbkrBroker — liveness policy', () => { } }) }) + +describe('IbkrBroker — OCA revision and echo verification on modifyOrder', () => { + function restingStop(): Order { + const order = new Order() + order.action = 'SELL' + order.orderType = 'STP LMT' + order.totalQuantity = new Decimal(10) + order.auxPrice = new Decimal('99') + order.lmtPrice = new Decimal('98.5') + return order + } + + /** An `openOrder` echo body derived from the resting order. */ + function echoOf(base: Order, overrides: Partial = {}): Order { + return Object.assign(new Order(), base, overrides) + } + + function brokerWithRestingOrder(existing: Order = restingStop()): { + broker: IbkrBroker + client: { placeOrder: ReturnType } + bridge: { requestOrder: ReturnType } + } { + const { broker, client, bridge } = brokerWithContractIo() + ;(broker as unknown as { getOrder: unknown }).getOrder = vi.fn(async () => ({ + contract: Object.assign(new Contract(), { conId: 12087820 }), + order: existing, + orderState: { status: 'Submitted' }, + })) + return { broker, client, bridge } + } + + // A re-place carrying a new ocaGroup + ocaType is rejected with 10327, and + // with ocaType omitted TWS accepts it and silently drops the group. + it.each(['ocaGroup', 'ocaType', 'parentId'] as const)( + 'refuses %s revision on a working order without calling the venue', + async (field) => { + const { broker, client } = brokerWithRestingOrder() + const changes = { ocaGroup: 'aapl-exit', ocaType: 3, parentId: 17 }[field] + + await expect(broker.modifyOrder('42', { [field]: changes } as Partial)) + .rejects.toThrow(/10327/) + expect(client.placeOrder).not.toHaveBeenCalled() + }, + ) + + it('names the cancel-and-re-place recipe in the refusal', async () => { + const { broker } = brokerWithRestingOrder() + await expect(broker.modifyOrder('42', { ocaGroup: 'aapl-exit' } as Partial)) + .rejects.toThrow(/Cancel this order and re-place it with ocaGroup set at placement time/) + }) + + it('leaves an unrelated modification free of OCA fields', async () => { + const { broker, client } = brokerWithRestingOrder() + + await broker.modifyOrder('42', { lmtPrice: new Decimal('97') }) + + const sent = client.placeOrder.mock.calls[0][2] as Order + expect(sent.ocaGroup).toBe('') + expect(sent.ocaType).toBe(0) + expect(sent.lmtPrice.toFixed()).toBe('97') + }) + + // IBKR modifies by re-placing on the same orderId, so TWS reads the message + // as a complete order and resets any field the merge fails to carry over. + it('preserves every untouched field of the resting order across the re-place', async () => { + const existing = restingStop() + existing.tif = 'GTC' + existing.outsideRth = true + existing.transmit = true + existing.account = 'DU1234567' + existing.ocaGroup = 'aapl-exit' + existing.ocaType = 1 + const { broker, client } = brokerWithRestingOrder(existing) + + await broker.modifyOrder('42', { lmtPrice: new Decimal('97') }) + + const [sentId, sentContract, sent] = client.placeOrder.mock.calls[0] as [number, Contract, Order] + expect(sentId).toBe(42) + expect(sentContract.conId).toBe(12087820) + expect(sent.action).toBe('SELL') + expect(sent.orderType).toBe('STP LMT') + expect(sent.totalQuantity.toFixed()).toBe('10') + expect(sent.auxPrice.toFixed()).toBe('99') + expect(sent.lmtPrice.toFixed()).toBe('97') + expect(sent.tif).toBe('GTC') + expect(sent.outsideRth).toBe(true) + expect(sent.transmit).toBe(true) + expect(sent.account).toBe('DU1234567') + // Carrying the working order's own group over is not a revision. + expect(sent.ocaGroup).toBe('aapl-exit') + expect(sent.ocaType).toBe(1) + }) + + // Rehydrated orders arrive with ocaType undefined rather than 0, and TWS + // encodes both as 0. + it('defaults an undefined ocaType on a group the working order already has', async () => { + const existing = restingStop() + existing.ocaGroup = 'aapl-exit' + ;(existing as unknown as { ocaType?: number }).ocaType = undefined + const { broker, client } = brokerWithRestingOrder(existing) + + await broker.modifyOrder('42', { lmtPrice: new Decimal('97') }) + + expect((client.placeOrder.mock.calls[0][2] as Order).ocaType).toBe(1) + }) + + // TWS can accept a re-place and still ignore individual fields. + it('fails the modify when the venue echoes the OLD value of a requested field', async () => { + const existing = restingStop() + const { broker, bridge } = brokerWithRestingOrder(existing) + bridge.requestOrder.mockImplementation(async () => ({ + contract: new Contract(), + order: echoOf(restingStop()), + orderState: { status: 'Submitted' }, + })) + + const result = await broker.modifyOrder('42', { lmtPrice: new Decimal('97') }) + + expect(result.success).toBe(false) + expect(result.error).toContain('ignored: lmtPrice (requested 97, working 98.5)') + expect(result.error).toContain('still working with its previous values') + }) + + it('succeeds when the echo carries the requested value', async () => { + const { broker, bridge } = brokerWithRestingOrder() + bridge.requestOrder.mockImplementation(async () => ({ + contract: new Contract(), + order: echoOf(restingStop(), { lmtPrice: new Decimal('97') }), + orderState: { status: 'Submitted' }, + })) + + const result = await broker.modifyOrder('42', { lmtPrice: new Decimal('97') }) + + expect(result.success).toBe(true) + expect(result.error).toBeUndefined() + expect(result.message).toBeUndefined() + }) + + // An orderStatus-only answer carries a blank Order, which echoes nothing. + it('reports unverified fields when the answer is orderStatus-only', async () => { + const { broker, bridge } = brokerWithRestingOrder() + bridge.requestOrder.mockImplementation(async () => ({ + contract: new Contract(), + order: new Order(), + orderState: { status: 'Submitted' }, + })) + + const result = await broker.modifyOrder('42', { lmtPrice: new Decimal('97'), tif: 'GTC' }) + + expect(result.success).toBe(true) + expect(result.message).toContain('did not carry lmtPrice, tif') + expect(result.message).toContain('unverified') + }) + + it('suppresses the unverified note when a real mismatch is present', async () => { + const { broker, bridge } = brokerWithRestingOrder() + bridge.requestOrder.mockImplementation(async () => ({ + contract: new Contract(), + // tif is absent from the echo; lmtPrice is present and stale. + order: echoOf(restingStop(), { tif: '' }), + orderState: { status: 'Submitted' }, + })) + + const result = await broker.modifyOrder('42', { lmtPrice: new Decimal('97'), tif: 'GTC' }) + + expect(result.success).toBe(false) + expect(result.error).toContain('lmtPrice') + expect(result.error).not.toContain('unverified') + }) + + it('declares TRAIL LIMIT among the supported order types', () => { + const { broker } = brokerWithContractIo() + expect(broker.getCapabilities().supportedOrderTypes).toContain('TRAIL LIMIT') + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 0060f911b2..09eb68c53d 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -23,6 +23,9 @@ import { OrderState, ContractDescription, type ContractDetails, + UNSET_DECIMAL, + UNSET_DOUBLE, + UNSET_INTEGER, } from '@traderalice/ibkr' import { BrokerError, @@ -39,12 +42,21 @@ import { type TpSlParams, type ExpandContractFilters, type ContractExpansion, + type Bar, + type BarParams, } from '../types.js' import '../../contract-ext.js' import { derivePositionMath } from '../../position-math.js' import { RequestBridge, acceptsCancelStatus } from './request-bridge.js' import { resolveSymbol } from './ibkr-contracts.js' import { applyStandaloneOcaType, buildIbkrBracket, refuseBracketOcaGroup } from './ibkr-bracket.js' +import { refuseOcaRevision } from '../../oca.js' +import { + IBKR_SUPPORTED_BAR_SIZES, + buildHistoricalRequest, + classifyHistoricalError, + decodeBars, +} from './ibkr-historical.js' import type { IbkrBrokerConfig } from './ibkr-types.js' // IB Gateway can defer a currentTime reply well past a second under its own @@ -57,6 +69,68 @@ const HEARTBEAT_MISS_THRESHOLD = 2 // accepts, so the exemption is bounded or a half-open socket hides forever // (issue #294). const HEARTBEAT_INBOUND_EXEMPTION_LIMIT = 3 + +/** Order fields a modify may revise, checked against the venue echo. */ +const MODIFIABLE_ECHO_FIELDS = [ + 'totalQuantity', 'lmtPrice', 'auxPrice', 'tif', 'orderType', + 'trailingPercent', 'trailStopPrice', 'goodTillDate', 'outsideRth', +] as const + +/** Render a field value for an operator-facing diff message. */ +function renderEchoValue(value: unknown): string { + return Decimal.isDecimal(value) ? (value as Decimal).toString() : String(value) +} + +/** + * Is a value the venue's "field absent" answer? IBKR sends UNSET sentinels and + * empty strings for fields it did not populate, which is not a rejected change. + */ +function isEchoAbsent(value: unknown): boolean { + if (value == null) return true + if (typeof value === 'string') return value === '' + if (Decimal.isDecimal(value)) return (value as Decimal).equals(UNSET_DECIMAL) + if (typeof value === 'number') return value === UNSET_DOUBLE || value === UNSET_INTEGER + return false +} + +/** Did the venue actually apply `requested` for this field? */ +function echoMatches(requested: unknown, echoed: unknown): boolean { + if (Decimal.isDecimal(requested) || Decimal.isDecimal(echoed)) { + try { + return new Decimal(requested as Decimal.Value).equals(new Decimal(echoed as Decimal.Value)) + } catch { return false } + } + return requested === echoed +} + +/** + * Compares each requested change against the venue's openOrder echo, because + * TWS can accept a re-place and still ignore individual fields. Anything the + * echo did not carry is `unverified` rather than a rejection. + */ +function verifyModifyEcho( + changes: Partial, + echoed: Order | undefined, +): { ignored: string[]; unverified: string[] } { + const ignored: string[] = [] + const unverified: string[] = [] + // An orderStatus-only answer carries a blank Order, which echoes nothing. + const hasBody = echoed != null && echoed.orderType !== '' + for (const field of MODIFIABLE_ECHO_FIELDS) { + const requested = changes[field] + if (requested == null) continue + if (!hasBody) { unverified.push(field); continue } + const actual = echoed[field] + if (isEchoAbsent(actual)) { unverified.push(field); continue } + if (!echoMatches(requested, actual)) { + ignored.push(`${field} (requested ${renderEchoValue(requested)}, working ${renderEchoValue(actual)})`) + } + } + // Confirmed mismatches are the actionable signal; only surface unverified + // fields when there is nothing concrete to report. + return { ignored, unverified: ignored.length > 0 ? [] : unverified } +} + const OPTION_MARK_SUCCESS_TTL_MS = 15_000 const OPTION_MARK_FAILURE_TTL_MS = 60_000 const OPTION_MARK_CONCURRENCY = 8 @@ -644,6 +718,9 @@ export class IbkrBroker implements IBroker { } async modifyOrder(orderId: string, changes: Partial): Promise { + // Refused outside the try/catch so it surfaces as a CONFIG error rather + // than an ordinary { success: false } venue rejection. + refuseOcaRevision('IBKR', changes) try { this._ensureAlive() // IBKR modifies orders by re-calling placeOrder with the same orderId @@ -661,18 +738,41 @@ export class IbkrBroker implements IBroker { if (changes.orderType) mergedOrder.orderType = changes.orderType if (changes.trailingPercent != null) mergedOrder.trailingPercent = changes.trailingPercent if (changes.trailStopPrice != null) mergedOrder.trailStopPrice = changes.trailStopPrice + if (changes.goodTillDate != null) mergedOrder.goodTillDate = changes.goodTillDate + if (changes.outsideRth != null) mergedOrder.outsideRth = changes.outsideRth + + // A group whose type is still 0 is ignored by TWS, so the orders would + // look linked here and behave independently at the venue. + const toSend = applyStandaloneOcaType(mergedOrder) const routedContract = await this.resolveRoutableContract(original.contract) await this._ensureWriteAlive() const numericId = parseInt(orderId, 10) const promise = this.bridge.requestOrder(numericId) - this.client.placeOrder(numericId, routedContract, mergedOrder) + this.client.placeOrder(numericId, routedContract, toSend) const result = await promise + // TWS can accept a re-place and still ignore individual fields, so the + // echo is compared before the ledger records the requested values. + const echo = verifyModifyEcho(changes, result.order) + if (echo.ignored.length > 0) { + return { + success: false, + orderId, + orderState: result.orderState, + error: + `Venue accepted the re-place of order ${orderId} but ignored: ${echo.ignored.join(', ')}. ` + + 'The order is still working with its previous values.', + } + } + return { success: true, orderId, orderState: result.orderState, + ...(echo.unverified.length > 0 + ? { message: `Modify of order ${orderId} accepted; the venue echo did not carry ${echo.unverified.join(', ')} (unverified).` } + : {}), } } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) } @@ -1017,12 +1117,83 @@ export class IbkrBroker implements IBroker { return { isOpen, timestamp: now } } + // ==================== Historical bars ==================== + + /** + * Serializes historical requests. TWS paces them across the whole connection + * (roughly 60 per 10 minutes, no identical request within 15s). + */ + private historicalQueue_: Promise = Promise.resolve() + + private enqueueHistorical(task: () => Promise): Promise { + const run = this.historicalQueue_.then(task, task) + // Keep the chain alive after a rejection so one failure can't wedge the + // queue, without swallowing the caller's error. + this.historicalQueue_ = run.catch(() => undefined) + return run + } + + /** + * Historical OHLCV via `reqHistoricalData`. Entitlement, pacing, and empty + * windows all arrive as error 162 and are separated by message text. + */ + async getHistorical(contract: Contract, params: BarParams): Promise { + this._ensureAlive() + const routedContract = await this.resolveRoutableContract(contract) + // secType picks the default price stream: spot FX (`CASH`) has no TRADES. + const request = buildHistoricalRequest(params, new Date(), routedContract.secType) + + return this.enqueueHistorical(async () => { + const reqId = this.bridge.allocReqId() + const promise = this.bridge.requestHistoricalBars(reqId) + this.client.reqHistoricalData( + reqId, + routedContract, + request.endDateTime, + request.durationStr, + request.barSizeSetting, + request.whatToShow, + request.useRTH, + request.formatDate, + false, // keepUpToDate: this is a one-shot query, not a subscription + [], + ) + try { + return decodeBars(await promise, params) + } catch (err) { + // Frees the HMDS slot; a cancel for a finished query is a harmless no-op. + try { this.client.cancelHistoricalData(reqId) } catch { /* best-effort */ } + const kind = classifyHistoricalError(err) + if (kind === 'empty') return [] + if (kind === 'pacing') { + throw new BrokerError( + 'NETWORK', + `IBKR historical-data pacing limit hit for ${resolveSymbol(routedContract) ?? 'contract'} ` + + `(${params.interval}). Retry shortly; reduce concurrent bar requests. Original: ` + + `${err instanceof Error ? err.message : String(err)}`, + ) + } + throw BrokerError.from(err) + } + }) + } + // ==================== Capabilities ==================== getCapabilities(): AccountCapabilities { return { supportedSecTypes: ['STK', 'OPT', 'FUT', 'FOP', 'CASH', 'WAR', 'BOND'], - supportedOrderTypes: ['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'MOC', 'LOC', 'REL'], + supportedOrderTypes: ['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC', 'LOC', 'REL'], + // Without the market-data subscription TWS answers error 162 rather than + // degrading to delayed bars, so the source is entitlement-gated. + historicalBars: { + supported: true, + quality: 'subscription', + supportedBarSizes: IBKR_SUPPORTED_BAR_SIZES, + // `useRTH` is a real server-side filter, so both sessions are honored + // rather than approximated client-side. + sessions: ['regular', 'extended'], + }, } } diff --git a/services/uta/src/domain/trading/brokers/ibkr/README.md b/services/uta/src/domain/trading/brokers/ibkr/README.md index 1821f209bb..546ef916cb 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/README.md +++ b/services/uta/src/domain/trading/brokers/ibkr/README.md @@ -70,6 +70,32 @@ All times in US/Eastern (ET): - **`reqMktData` (snapshot/streaming)**: CAN return overnight session prices if the contract supports overnight trading. This is why TWS front-end still shows last price changes after 20:00 ET. - **Conclusion**: If we need accurate overnight prices for snapshots, we must use `reqMktData` instead of relying on `updatePortfolio()` cached prices. +### Historical bar sessions + +`reqHistoricalData`'s `useRTH` decides whether the returned series is the +regular session only or the continuous tape (pre/post + overnight). Which one +is correct is an INSTRUMENT property, not a caller preference, so the choice is +not made in this adapter: `UnifiedTradingAccount.getHistorical` resolves it via +`resolveBarSession` (`services/uta/src/domain/trading/bar-session.ts`) and +passes `BarParams.session` down already decided. `ibkr-historical.ts` only maps +`'regular' → useRTH 1` and anything else → `0`. + +| secType | Default session | Explicit request | +|---|---|---| +| `STK`, `OPT`, `WAR`, `BOND`, `IND`, `FUND`, unknown | `regular` | `extended` honored | +| `FUT`, `FOP`, `CFD`, `CMDTY` | `extended` | `regular` honored | +| `CASH`, `CRYPTO` | `extended` | `regular` REFUSED (forced back to `extended`) | + +IBKR declares `historicalBars.sessions: ['regular', 'extended']`, so both are +served for real. A broker that declares no `sessions` (Alpaca, CCXT) has no +filter at all; UTA then returns the continuous tape with `forced: true` rather +than labeling extended bars "regular". + +The response is `{ bars, session, forced }` all the way out through +`POST /uta/:id/historical` and the Alice SDK, and BarService stamps +`meta.session` / `meta.sessionForced`. A consumer never has to guess whether a +series contains overnight prints. + ## Socket Error Handling The `@traderalice/ibkr` Connection class is a Node port of Python's `Connection`. @@ -111,6 +137,30 @@ Known-dead transport state reaches UTA through an explicit connection-state list from birth. After fill, raising or trailing a stop is `modifyOrder` on that child id. `parentId` and the OCA group survive the merge. +## OCA membership is placement-time only + +`modifyOrder` refuses `ocaGroup`, `ocaType`, and `parentId` before touching +the venue (`refuseOcaRevision`, a `CONFIG` `BrokerError`). Confirmed live: +re-placing a resting STP LMT with a new group and `ocaType=1` is rejected +with **error 10327** ("OCA group type revision is not allowed"), and with +`ocaType` omitted TWS *accepts* the re-place and silently drops the group: +the working order rests with no OCA membership while the caller believes the +legs are linked. + +To link an existing protective order, place the new leg with `ocaGroup` set, +then cancel the old order and re-place it with the same group (sequence the +two so protection stays continuous). + +Because that failure mode was an accepted-but-ignored re-place, `modifyOrder` +now verifies the venue echo generally: every requested field +(`totalQuantity`, `lmtPrice`, `auxPrice`, `tif`, `orderType`, +`trailingPercent`, `trailStopPrice`, `goodTillDate`, `outsideRth`) is +compared against the `openOrder` echo (Decimal equality for numerics, UNSET +sentinels treated as absent). A mismatch returns `success: false` naming the +ignored fields, so the ledger keeps the old working values. An +`orderStatus`-only answer carries no order body, so its fields are reported +as "unverified" in `message` rather than failed. + A standalone `--oca-group` without `ocaType` defaults to `ocaType=1` so a post-fill STP+TP pair can rest together. Same-name OCA with type 0 is ignored by TWS and looks like a second short. diff --git a/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts b/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts index 43a5a6a2ca..0f0315c7d9 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/ibkr-bracket.ts @@ -125,9 +125,16 @@ export function buildIbkrBracket( return { parentId, parent, children } } -/** Same-name OCA with type 0 is ignored by TWS and looks like a second short. */ +/** + * Same-name OCA with type 0 is ignored by TWS and looks like a second short. + * + * The guard is falsiness, not `=== 0`: an `Order` rebuilt from `commit.json` + * (or any plain object reaching the modify path) can carry `ocaType: + * undefined`, which the encoder writes as 0 just the same. Matching only the + * literal 0 would let exactly the rehydrated case through unlinked. + */ export function applyStandaloneOcaType(order: Order): Order { - if (order.ocaGroup && order.ocaType === 0) { + if (order.ocaGroup && !order.ocaType) { const clone = cloneOrder(order) clone.ocaType = IBKR_OCA_CANCEL_WITH_BLOCK return clone diff --git a/services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.spec.ts b/services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.spec.ts new file mode 100644 index 0000000000..d24f4f67ff --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.spec.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, vi } from 'vitest' +import Decimal from 'decimal.js' +import { Contract, BarData, UNSET_DECIMAL } from '@traderalice/ibkr' +import { IbkrBroker } from './IbkrBroker.js' +import { + IBKR_BAR_SIZE, + buildHistoricalRequest, + classifyHistoricalError, + decodeBars, + formatDuration, + formatEndDateTime, + parseBarDate, +} from './ibkr-historical.js' +import type { BarParams } from '../types.js' + +const NOW = new Date('2026-09-03T16:00:00.000Z') + +function bar(dateSeconds: number, o: number, h: number, l: number, c: number, v: string | null): BarData { + const b = new BarData() + b.date = String(dateSeconds) + b.open = o + b.high = h + b.low = l + b.close = c + b.volume = v == null ? UNSET_DECIMAL : new Decimal(v) + return b +} + +// ==================== Request mapping ==================== + +describe('IBKR historical — request mapping', () => { + it('maps every OpenAlice interval to a native TWS bar size', () => { + expect(IBKR_BAR_SIZE).toEqual({ + '1m': '1 min', + '5m': '5 mins', + '15m': '15 mins', + '30m': '30 mins', + '1h': '1 hour', + '4h': '4 hours', + '1d': '1 day', + '1w': '1 week', + }) + }) + + it('derives a duration from limit when no window is given, and leaves endDateTime empty for "now"', () => { + const req = buildHistoricalRequest({ interval: '1m', limit: 60 }, NOW) + expect(req).toEqual({ + endDateTime: '', + // Widened into calendar time and floored at 4 days so a Saturday request + // still reaches Friday, then clamped to the 1 D ceiling for `1 min` bars. + durationStr: '1 D', + barSizeSetting: '1 min', + whatToShow: 'TRADES', + useRTH: 0, + formatDate: 2, + }) + }) + + it('derives the duration from an explicit start/end window', () => { + const req = buildHistoricalRequest({ + interval: '1h', + start: new Date('2026-08-27T16:00:00.000Z'), + end: new Date('2026-09-03T16:00:00.000Z'), + }, NOW) + expect(req.durationStr).toBe('7 D') + expect(req.endDateTime).toBe('20260903-16:00:00') + expect(req.barSizeSetting).toBe('1 hour') + }) + + it('honors whatToShow from params, defaulting to TRADES', () => { + expect(buildHistoricalRequest({ interval: '1d', limit: 5 }, NOW)) + .toMatchObject({ whatToShow: 'TRADES' }) + expect(buildHistoricalRequest({ interval: '1d', limit: 5, whatToShow: 'MIDPOINT' }, NOW)) + .toMatchObject({ whatToShow: 'MIDPOINT' }) + }) + + // The session arrives already resolved; the adapter only translates it to + // TWS's flag. + it('maps a regular session to useRTH 1 and an extended one to 0', () => { + expect(buildHistoricalRequest({ interval: '1d', limit: 5, session: 'regular' }, NOW).useRTH).toBe(1) + expect(buildHistoricalRequest({ interval: '1d', limit: 5, session: 'extended' }, NOW).useRTH).toBe(0) + }) + + it('treats an unresolved session as the continuous tape', () => { + expect(buildHistoricalRequest({ interval: '1d', limit: 5 }, NOW).useRTH).toBe(0) + }) + + it('keeps the MIDPOINT default for CASH and CFD regardless of session', () => { + expect(buildHistoricalRequest({ interval: '1d', limit: 5, session: 'extended' }, NOW, 'CASH').whatToShow).toBe('MIDPOINT') + expect(buildHistoricalRequest({ interval: '1d', limit: 5, session: 'extended' }, NOW, 'CFD').whatToShow).toBe('MIDPOINT') + }) + + // TWS rejects an over-long duration outright (162) rather than truncating. + it('clamps a duration beyond the per-bar-size ceiling', () => { + const req = buildHistoricalRequest({ + interval: '1m', + start: new Date('2025-09-03T16:00:00.000Z'), + end: new Date('2026-09-03T16:00:00.000Z'), + }, NOW) + expect(req.durationStr).toBe('1 D') + }) + + // The ceilings come from TWS's duration↔bar-size table, and exceeding one is + // a hard 162 for the whole request rather than a truncated answer. + it('clamps each bar size to the duration TWS actually accepts for it', () => { + const year = 400 * 86_400_000 + expect(formatDuration(year, '1m')).toBe('1 D') + expect(formatDuration(year, '5m')).toBe('7 D') + expect(formatDuration(year, '15m')).toBe('7 D') + expect(formatDuration(year, '30m')).toBe('30 D') + expect(formatDuration(year, '1h')).toBe('30 D') + expect(formatDuration(year, '4h')).toBe('30 D') + expect(formatDuration(year, '1d')).toBe('365 D') + // Past a year TWS expects the `Y` unit rather than an oversized `D`. + expect(formatDuration(year, '1w')).toBe('2 Y') + }) + + // The documented `S` buckets stop at 28800 S (8 hours), and a longer seconds + // request is an unlisted pairing rather than a wider window. + it('switches from seconds to days past the 8-hour S bucket', () => { + expect(formatDuration(8 * 3_600_000, '5m')).toBe('28800 S') + expect(formatDuration(8 * 3_600_000 + 1_000, '5m')).toBe('1 D') + }) + + it('never emits a zero-length duration', () => { + expect(formatDuration(0, '1m')).toBe('60 S') + // `S` is only valid with an intraday bar size, so a daily request that + // rounds to under a day is still expressed in days. + expect(formatDuration(-5, '1d')).toBe('1 D') + }) + + // TWS rejects an `S` duration paired with a daily/weekly bar size outright, + // so a one-day daily window must not render as "86400 S". + it('never expresses a daily or weekly duration in seconds', () => { + expect(buildHistoricalRequest({ + interval: '1d', + start: new Date('2026-09-02T16:00:00.000Z'), + end: new Date('2026-09-03T16:00:00.000Z'), + }, NOW).durationStr).toBe('1 D') + expect(formatDuration(1_000, '1w')).toBe('7 D') + expect(formatDuration(20 * 3_600_000, '1d')).toBe('1 D') + }) + + // Spot FX has no TRADES stream; TWS answers 162 instead of substituting one. + it('defaults CASH contracts to MIDPOINT and everything else to TRADES', () => { + expect(buildHistoricalRequest({ interval: '1h', limit: 5 }, NOW, 'CASH').whatToShow).toBe('MIDPOINT') + expect(buildHistoricalRequest({ interval: '1h', limit: 5 }, NOW, 'STK').whatToShow).toBe('TRADES') + expect(buildHistoricalRequest({ interval: '1h', limit: 5, whatToShow: 'BID' }, NOW, 'CASH').whatToShow).toBe('BID') + }) + + // A wall-clock window of exactly N intervals returns nothing at all when the + // request lands on a weekend. + it('widens an intraday limit window into calendar time so a closed session still returns bars', () => { + expect(buildHistoricalRequest({ interval: '5m', limit: 100 }, NOW).durationStr).toBe('4 D') + expect(buildHistoricalRequest({ interval: '1d', limit: 100 }, NOW).durationStr).toBe('153 D') + }) + + it('formats endDateTime as the UTC yyyyMMdd-HH:mm:ss form TWS expects', () => { + expect(formatEndDateTime(new Date('2026-01-09T05:06:07.899Z'))).toBe('20260109-05:06:07') + }) +}) + +// ==================== Bar decoding ==================== + +describe('IBKR historical — bar decoding', () => { + it('decodes epoch-second bars into the protocol shape', () => { + const params: BarParams = { interval: '1h' } + const bars = decodeBars([bar(1_772_553_600, 1.5, 2, 1.25, 1.75, '1234')], params) + expect(bars).toEqual([{ + timestamp: new Date('2026-03-03T16:00:00.000Z'), + open: '1.5', + high: '2', + low: '1.25', + close: '1.75', + volume: '1234', + }]) + }) + + it('accepts the YYYYMMDD form some gateways return for daily bars', () => { + expect(parseBarDate('20260903')).toEqual(new Date('2026-09-03T00:00:00.000Z')) + }) + + // MIDPOINT/BID/ASK bars carry no size, and the sentinel must never reach the + // agent boundary as a volume. + it('renders an UNSET volume as 0 rather than the IBKR sentinel', () => { + const [decoded] = decodeBars([bar(1_772_553_600, 1, 1, 1, 1, null)], { interval: '1h' }) + expect(decoded!.volume).toBe('0') + }) + + it('re-bounds the response to the requested window and tail-slices to limit', () => { + const hour = 3_600 + const base = 1_772_553_600 + const raw = [0, 1, 2, 3, 4].map(i => bar(base + i * hour, i, i, i, i, '1')) + const bars = decodeBars(raw, { + interval: '1h', + start: new Date((base + hour) * 1000), + end: new Date((base + 3 * hour) * 1000), + limit: 2, + }) + expect(bars.map(b => b.close)).toEqual(['2', '3']) + }) + + it('returns bars in ascending time order', () => { + const hour = 3_600 + const base = 1_772_553_600 + const raw = [bar(base + hour, 2, 2, 2, 2, '1'), bar(base, 1, 1, 1, 1, '1')] + expect(decodeBars(raw, { interval: '1h' }).map(b => b.close)).toEqual(['1', '2']) + }) +}) + +// ==================== Error 162 triage ==================== + +describe('IBKR historical — error 162 triage', () => { + it('separates an empty window, a pacing violation, and a real failure', () => { + expect(classifyHistoricalError(new Error('IBKR error 162: HMDS query returned no data'))).toBe('empty') + expect(classifyHistoricalError( + new Error('IBKR error 162: Historical Market Data Service error message:Historical data request pacing violation'), + )).toBe('pacing') + expect(classifyHistoricalError( + new Error('IBKR error 162: Historical Market Data Service error message:invalid step'), + )).toBe('error') + expect(classifyHistoricalError(new Error('IBKR error 200: No security definition found'))).toBe('error') + }) +}) + +// ==================== Broker wiring ==================== + +function historicalBroker(bars: BarData[] | Error): { + broker: IbkrBroker + client: { reqHistoricalData: ReturnType; cancelHistoricalData: ReturnType } +} { + const broker = new IbkrBroker({ id: 'ibkr-test', host: '127.0.0.1', port: 7497, clientId: 91 }) + const bridge = { + connectionDead: false, + allocReqId: vi.fn(() => 10_001), + requestHistoricalBars: vi.fn(async () => { + if (bars instanceof Error) throw bars + return bars + }), + } + const client = { + reqHistoricalData: vi.fn(), + cancelHistoricalData: vi.fn(), + } + ;(broker as unknown as { bridge: unknown }).bridge = bridge + ;(broker as unknown as { client: unknown }).client = client + return { broker, client } +} + +function aapl(): Contract { + const c = new Contract() + c.symbol = 'AAPL' + c.secType = 'STK' + return c +} + +describe('IbkrBroker — getHistorical', () => { + it('issues one reqHistoricalData with the mapped arguments and decodes the bars', async () => { + const { broker, client } = historicalBroker([bar(1_772_553_600, 1, 2, 0.5, 1.5, '10')]) + + const bars = await broker.getHistorical(aapl(), { interval: '15m', limit: 4 }) + + expect(client.reqHistoricalData).toHaveBeenCalledOnce() + const [reqId, contract, endDateTime, durationStr, barSize, whatToShow, useRTH, formatDate, keepUpToDate] + = client.reqHistoricalData.mock.calls[0] as unknown[] + expect(reqId).toBe(10_001) + expect((contract as Contract).symbol).toBe('AAPL') + // Routing defaults are applied at the write boundary, same as quotes. + expect((contract as Contract).exchange).toBe('SMART') + expect(endDateTime).toBe('') + expect(durationStr).toBe('4 D') + expect(barSize).toBe('15 mins') + expect(whatToShow).toBe('TRADES') + expect(useRTH).toBe(0) + expect(formatDate).toBe(2) + expect(keepUpToDate).toBe(false) + expect(bars).toHaveLength(1) + expect(bars[0]!.close).toBe('1.5') + }) + + it('returns an empty series (not a failure) when TWS reports no data', async () => { + const { broker } = historicalBroker(new Error('IBKR error 162: HMDS query returned no data')) + await expect(broker.getHistorical(aapl(), { interval: '1d', limit: 5 })).resolves.toEqual([]) + }) + + it('reports a pacing violation as a transient NETWORK error', async () => { + const { broker, client } = historicalBroker( + new Error('IBKR error 162: Historical data request pacing violation'), + ) + await expect(broker.getHistorical(aapl(), { interval: '1m', limit: 5 })) + .rejects.toMatchObject({ code: 'NETWORK' }) + expect(client.cancelHistoricalData).toHaveBeenCalledWith(10_001) + }) + + it('serializes concurrent requests so TWS sees one query at a time', async () => { + const { broker, client } = historicalBroker([]) + let inFlight = 0 + let maxInFlight = 0 + ;(broker as unknown as { bridge: { requestHistoricalBars: unknown } }).bridge.requestHistoricalBars = + vi.fn(async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise(resolve => setTimeout(resolve, 5)) + inFlight -= 1 + return [] + }) + + await Promise.all([ + broker.getHistorical(aapl(), { interval: '1h', limit: 2 }), + broker.getHistorical(aapl(), { interval: '1h', limit: 2 }), + broker.getHistorical(aapl(), { interval: '1h', limit: 2 }), + ]) + + expect(maxInFlight).toBe(1) + expect(client.reqHistoricalData).toHaveBeenCalledTimes(3) + }) + + // The serializing queue chains every request onto the previous promise, so a + // rejection left to propagate would wedge every later query. + it('keeps serving requests after one rejects', async () => { + const { broker } = historicalBroker([]) + const bridge = (broker as unknown as { bridge: { requestHistoricalBars: unknown } }).bridge + bridge.requestHistoricalBars = vi.fn() + .mockRejectedValueOnce(new Error('IBKR error 162: Historical Market Data Service error message:invalid step')) + .mockResolvedValue([bar(1_772_553_600, 1, 1, 1, 1, '1')]) + + await expect(broker.getHistorical(aapl(), { interval: '1h', limit: 2 })).rejects.toThrow(/invalid step/) + await expect(broker.getHistorical(aapl(), { interval: '1h', limit: 2 })).resolves.toHaveLength(1) + }) + + it('declares the historicalBars capability with its real bar sizes', () => { + const { broker } = historicalBroker([]) + const caps = broker.getCapabilities().historicalBars + expect(caps).toEqual({ + supported: true, + quality: 'subscription', + supportedBarSizes: ['1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w'], + sessions: ['regular', 'extended'], + }) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.ts b/services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.ts new file mode 100644 index 0000000000..208ae195a9 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ibkr/ibkr-historical.ts @@ -0,0 +1,226 @@ +/** + * IBKR historical-bar request mapping and bar decoding. `reqHistoricalData` is + * anchored at `endDateTime` and reaches backwards by `durationStr`, so a + * `BarParams` window is converted into that shape and re-bounded locally. + */ + +import Decimal from 'decimal.js' +import type { BarData } from '@traderalice/ibkr' +import { UNSET_DECIMAL } from '@traderalice/ibkr' +import { BrokerError, type Bar, type BarInterval, type BarParams } from '../types.js' + +/** BarInterval → TWS `barSizeSetting`. */ +export const IBKR_BAR_SIZE: Record = { + '1m': '1 min', + '5m': '5 mins', + '15m': '15 mins', + '30m': '30 mins', + '1h': '1 hour', + '4h': '4 hours', + '1d': '1 day', + '1w': '1 week', +} + +export const IBKR_SUPPORTED_BAR_SIZES = Object.keys(IBKR_BAR_SIZE) as BarInterval[] + +const MINUTE_MS = 60_000 +const DAY_MS = 86_400_000 + +const INTERVAL_MS: Record = { + '1m': MINUTE_MS, + '5m': 5 * MINUTE_MS, + '15m': 15 * MINUTE_MS, + '30m': 30 * MINUTE_MS, + '1h': 60 * MINUTE_MS, + '4h': 4 * 60 * MINUTE_MS, + '1d': DAY_MS, + '1w': 7 * DAY_MS, +} + +/** + * Per-bar-size duration ceiling, in days, from the TWS "Historical Data + * Limitations" table. Exceeding it fails the whole request with error 162 + * rather than truncating. + */ +const MAX_DURATION_DAYS: Record = { + '1m': 1, + '5m': 7, + '15m': 7, + '30m': 30, + '1h': 30, + '4h': 30, + '1d': 365, + '1w': 730, +} + +/** Pad the derived window so an in-progress or partially-formed bar can't eat the request. */ +const WINDOW_PADDING_INTERVALS = 2 + +/** + * `limit` counts bars while `durationStr` spans calendar time, so reaching back + * exactly N intervals returns nothing over a weekend. Over-fetching is + * harmless: `decodeBars` tail-slices to `limit`. + */ +const SESSION_TO_CALENDAR = 4 +/** Wide enough to reach back over a weekend plus an adjacent holiday. */ +const MIN_INTRADAY_SPAN_MS = 4 * DAY_MS +/** Daily bars are calendar-anchored; only the ~5-trading-days-in-7 cadence. */ +const TRADING_DAY_TO_CALENDAR = 1.5 + +/** Calendar span that should contain `bars` bars of `interval`. */ +function calendarSpanFor(bars: number, interval: BarInterval): number { + const raw = bars * INTERVAL_MS[interval] + if (interval === '1w') return raw + if (INTERVAL_MS[interval] >= DAY_MS) return Math.ceil(raw * TRADING_DAY_TO_CALENDAR) + return Math.max(raw * SESSION_TO_CALENDAR, MIN_INTRADAY_SPAN_MS) +} + +/** + * `TRADES` does not exist for spot FX / metals (`CASH`) or CFDs; TWS answers + * error 162 rather than substituting a stream. + */ +export function defaultWhatToShow(secType: string): string { + return secType === 'CASH' || secType === 'CFD' ? 'MIDPOINT' : 'TRADES' +} + +export interface IbkrHistoricalRequest { + endDateTime: string + durationStr: string + barSizeSetting: string + whatToShow: string + useRTH: number + formatDate: number +} + +/** TWS accepts `yyyyMMdd-HH:mm:ss`, interpreted as UTC when no zone is given. */ +export function formatEndDateTime(end: Date): string { + const iso = end.toISOString() // 2026-09-03T12:34:56.789Z + return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}-${iso.slice(11, 19)}` +} + +/** + * TWS's largest documented `S` bucket is `28800 S` (8 hours). A longer seconds + * request is an unlisted pairing that TWS answers with 162. + */ +const MAX_SECONDS_SPAN_MS = 28_800_000 + +/** Days beyond which TWS expects the `Y` unit rather than a very large `D`. */ +const MAX_DAYS_SPAN_DAYS = 365 + +/** + * Renders a span into a TWS `durationStr`. Unit selection is a correctness + * constraint: TWS rejects an unlisted (duration unit, bar size) pair with 162. + */ +export function formatDuration(spanMs: number, interval: BarInterval): string { + const maxMs = MAX_DURATION_DAYS[interval] * DAY_MS + const clamped = Math.min(Math.max(spanMs, INTERVAL_MS[interval]), maxMs) + if (clamped <= MAX_SECONDS_SPAN_MS && INTERVAL_MS[interval] < DAY_MS) { + // Round UP to whole seconds so a sub-second remainder can't drop a bar. + return `${Math.max(1, Math.ceil(clamped / 1000))} S` + } + const days = Math.max(1, Math.ceil(clamped / DAY_MS)) + if (days > MAX_DAYS_SPAN_DAYS) return `${Math.ceil(days / 365)} Y` + return `${days} D` +} + +/** + * Maps `BarParams` onto the `reqHistoricalData` argument shape. `now` is + * injected so the derived window is deterministic under test. + */ +export function buildHistoricalRequest( + params: BarParams, + now = new Date(), + secType = '', +): IbkrHistoricalRequest { + const barSizeSetting = IBKR_BAR_SIZE[params.interval] + if (!barSizeSetting) { + throw new BrokerError('EXCHANGE', `IBKR has no native bar size for the ${params.interval} interval`) + } + + const endMs = params.end?.getTime() ?? now.getTime() + const limit = params.limit == null ? undefined : Math.max(1, Math.floor(params.limit)) + + // Without an explicit window, reach back far enough in calendar time to + // contain `limit` bars plus padding. + const spanMs = params.start != null + ? endMs - params.start.getTime() + : calendarSpanFor((limit ?? 1) + WINDOW_PADDING_INTERVALS, params.interval) + + return { + // An empty endDateTime means "now" to TWS. Sending it rather than a + // formatted clock reading avoids requesting bars from the future when the + // local clock runs ahead of the gateway's. + endDateTime: params.end ? formatEndDateTime(params.end) : '', + durationStr: formatDuration(spanMs, params.interval), + barSizeSetting, + whatToShow: params.whatToShow ?? defaultWhatToShow(secType), + // The session is already resolved per instrument upstream + // (`resolveBarSession`); an undefined value can only mean a direct adapter + // call, which takes the continuous tape. + useRTH: params.session === 'regular' ? 1 : 0, + // 2 = epoch seconds. Format 1 changes shape with bar size and takes its + // timezone from gateway settings. + formatDate: 2, + } +} + +/** + * `BarData.date` under `formatDate: 2` is epoch seconds. Some gateway builds + * still return `YYYYMMDD` for daily and weekly bars, so both are accepted. + */ +export function parseBarDate(date: string): Date { + const trimmed = date.trim() + if (/^\d{8}$/.test(trimmed)) { + return new Date(`${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6, 8)}T00:00:00.000Z`) + } + const seconds = Number(trimmed) + if (!Number.isFinite(seconds)) { + throw new BrokerError('EXCHANGE', `Unparseable IBKR bar date: ${JSON.stringify(date)}`) + } + return new Date(seconds * 1000) +} + +/** + * Decodes TWS bars into the protocol shape and re-applies the caller's bounds, + * because the duration window returns a superset of what was asked for. + */ +export function decodeBars(raw: BarData[], params: BarParams): Bar[] { + const lowerBound = params.start?.getTime() + const upperBound = params.end?.getTime() + const limit = params.limit == null ? undefined : Math.max(1, Math.floor(params.limit)) + + const bars: Bar[] = [] + for (const bar of raw) { + const timestamp = parseBarDate(bar.date) + const ms = timestamp.getTime() + if (lowerBound != null && ms < lowerBound) continue + if (upperBound != null && ms > upperBound) continue + bars.push({ + timestamp, + open: String(bar.open), + high: String(bar.high), + low: String(bar.low), + close: String(bar.close), + // Volume is UNSET or -1 for whatToShow values that carry no size + // (MIDPOINT, BID, ASK), which means no volume. + volume: bar.volume == null || bar.volume.equals(UNSET_DECIMAL) || bar.volume.isNegative() + ? '0' + : new Decimal(bar.volume).toFixed(), + }) + } + bars.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + return limit == null ? bars : bars.slice(-limit) +} + +/** + * TWS reports no-data, pacing violations, and genuine query errors under the + * same code 162, distinguished only by message text. An empty window and a + * pacing violation are both transient, so neither may disable the account. + */ +export function classifyHistoricalError(err: unknown): 'empty' | 'pacing' | 'error' { + const msg = err instanceof Error ? err.message : String(err) + if (!/error 162|historical market data service/i.test(msg)) return 'error' + if (/pacing violation|too many requests/i.test(msg)) return 'pacing' + if (/no data|query returned no data|hmds query returned no data/i.test(msg)) return 'empty' + return 'error' +} 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 e7ae6d3cb2..30b7f60938 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -25,6 +25,7 @@ import { type OrderState, type EClient, type TickAttrib, + type BarData, } from '@traderalice/ibkr' import { BrokerError, type BrokerConnectionStateEvent } from '../types.js' import { classifyIbkrError } from './ibkr-contracts.js' @@ -61,6 +62,11 @@ interface CurrentTimeWaiter { const CURRENT_TIME_REPLY_GRACE_MS = 30_000 const DEFAULT_TIMEOUT_MS = 10_000 +/** + * Historical bars come from the HMDS farm, not the trading socket, and a year + * of 1-minute bars outruns the ordinary 10s budget. + */ +const HISTORICAL_TIMEOUT_MS = 30_000 const SNAPSHOT_TIMEOUT_MS = 12_500 const ACCOUNT_READY_TIMEOUT_MS = 20_000 @@ -284,6 +290,14 @@ export class RequestBridge extends DefaultEWrapper { return this.request(reqId, timeoutMs) } + /** + * Registers a historical-bar request. An empty result set is a legitimate + * answer, so the collector resolves with `[]` rather than timing out. + */ + requestHistoricalBars(reqId: number, timeoutMs = HISTORICAL_TIMEOUT_MS): Promise { + return this.requestCollector(reqId, timeoutMs) + } + // ---- Mode B: orderId-based requests ---- /** @@ -702,6 +716,16 @@ export class RequestBridge extends DefaultEWrapper { this.resolveCollector(reqId) } + // ---- Historical bars (collector) ---- + + override historicalData(reqId: number, bar: BarData): void { + this.pushCollector(reqId, bar) + } + + override historicalDataEnd(reqId: number, _start: string, _end: string): void { + this.resolveCollector(reqId) + } + // ---- Option chain parameters (collector) ---- override securityDefinitionOptionParameter( diff --git a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts index e48e74be94..0e45c0a8af 100644 --- a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts +++ b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts @@ -40,6 +40,7 @@ import { type MarketClock, type TpSlParams, } from '../types.js' +import { refuseOcaLinkage } from '../../oca.js' import '../../contract-ext.js' import { buildPosition } from '../contract-builder.js' import type { FxService } from '../../fx-service.js' @@ -269,6 +270,7 @@ export class LongbridgeBroker implements IBroker { // ---- Trading operations ---- async placeOrder(contract: Contract, order: Order, _tpsl?: TpSlParams): Promise { + refuseOcaLinkage('Longbridge', order) const symbol = resolveSymbol(contract) if (!symbol) { return { success: false, error: 'Cannot resolve contract to Longbridge symbol' } @@ -313,6 +315,7 @@ export class LongbridgeBroker implements IBroker { } async modifyOrder(orderId: string, changes: Partial): Promise { + refuseOcaLinkage('Longbridge', changes) if (changes.totalQuantity == null || changes.totalQuantity.equals(UNSET_DECIMAL)) { return { success: false, error: 'modifyOrder requires totalQuantity for Longbridge' } } diff --git a/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts b/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts index 06c5668aa7..7763c4e377 100644 --- a/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts +++ b/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts @@ -30,6 +30,7 @@ import { type MarketClock, type TpSlParams, } from '../../types.js' +import { refuseOcaLinkage } from '../../../oca.js' import '../../../contract-ext.js' import { buildContract, buildPosition } from '../../contract-builder.js' @@ -212,6 +213,7 @@ export class LeverupBroker implements IBroker { async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise { this.ensureInit() + refuseOcaLinkage('LeverUp', order) if (order.orderType !== 'MKT') { return { success: false, error: `LeverUp OCT only supports market orders (got ${order.orderType}). Limit orders are not exposed via the OCT relayer.` } diff --git a/services/uta/src/domain/trading/oca.ts b/services/uta/src/domain/trading/oca.ts new file mode 100644 index 0000000000..24ec716fc9 --- /dev/null +++ b/services/uta/src/domain/trading/oca.ts @@ -0,0 +1,91 @@ +/** + * One-Cancels-All (OCA) and bracket-linkage vocabulary. `ocaType` selects the + * semantics TWS applies: + * + * 1 CANCEL_WITH_BLOCK cancel the remaining orders, block partial overfill + * 2 REDUCE_WITH_BLOCK reduce the remaining orders' size, block overfill + * 3 REDUCE_NON_BLOCK reduce the remaining orders' size, allow overfill + * + * TWS silently ignores a group whose type is 0, so a dropped or unset link has + * to be refused loudly rather than placed as an unlinked order. + */ + +import { BrokerError } from './brokers/types.js' + +/** The ocaType values TWS accepts (0 = unset, and never a valid request). */ +export const OCA_TYPES = [1, 2, 3] as const + +export type OcaType = (typeof OCA_TYPES)[number] + +/** Validate a caller-supplied ocaType at the staging boundary. */ +export function assertOcaType(value: number): OcaType { + if (value !== 1 && value !== 2 && value !== 3) { + throw new Error( + `ocaType must be 1 (CANCEL_WITH_BLOCK), 2 (REDUCE_WITH_BLOCK) or 3 (REDUCE_NON_BLOCK); got ${String(value)}. ` + + 'Omit it to use the broker default.', + ) + } + return value +} + +/** + * Parses a caller-supplied `parentId` at the staging boundary. Coercing a + * malformed id to `0` would rest the order standalone while the caller believes + * it is bracket-attached. + */ +export function parseOrderLinkId(value: string | number, op: string): number { + // `parseInt` stops at the first non-digit, so '12abc' and '1.9' would pass + // as ids the caller never meant. + const parsed = typeof value === 'number' ? value : (/^-?\d+$/.test(value.trim()) ? Number(value.trim()) : NaN) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error(`${op}: parentId must be a numeric order id; got ${JSON.stringify(value)}.`) + } + return parsed +} + +/** The subset of Order fields that express OCA / bracket linkage. */ +export interface OcaLinkageFields { + ocaGroup?: string + ocaType?: number + parentId?: number +} + +/** + * Refuses OCA/bracket linkage on a broker that cannot express it. Call it + * before the write's try/catch so the error propagates instead of folding into + * a `{ success: false }` result. + */ +export function refuseOcaLinkage(brokerLabel: string, order: OcaLinkageFields): void { + const present: string[] = [] + if (order.ocaGroup) present.push('ocaGroup') + if (order.ocaType) present.push('ocaType') + if (order.parentId) present.push('parentId') + if (present.length === 0) return + throw new BrokerError( + 'CONFIG', + `${brokerLabel} has no One-Cancels-All / bracket-linkage primitive, so ${present.join(', ')} cannot be honored. ` + + 'Refusing rather than silently placing an UNLINKED order. Use an IBKR account for OCA linkage, ' + + 'or manage the exit legs explicitly.', + ) +} + +/** + * Refuses an attempt to revise OCA / bracket linkage on a working order: IBKR + * answers 10327, or accepts the re-place and silently drops the group. Call it + * before the modify's try/catch so the error propagates. + */ +export function refuseOcaRevision(brokerLabel: string, changes: OcaLinkageFields): void { + const present: string[] = [] + if (changes.ocaGroup) present.push('ocaGroup') + if (changes.ocaType) present.push('ocaType') + if (changes.parentId) present.push('parentId') + if (present.length === 0) return + throw new BrokerError( + 'CONFIG', + `${brokerLabel} rejects OCA / bracket revision on a working order (error 10327, ` + + `"OCA group type revision is not allowed"), so ${present.join(', ')} cannot be modified in place. ` + + 'Refusing rather than sending a re-place the venue would accept while silently DROPPING the link. ' + + 'Cancel this order and re-place it with ocaGroup set at placement time (place the new leg with the ' + + 'group first so protection stays continuous).', + ) +} diff --git a/services/uta/src/http/routes-trading-historical.spec.ts b/services/uta/src/http/routes-trading-historical.spec.ts new file mode 100644 index 0000000000..848c2ea9c7 --- /dev/null +++ b/services/uta/src/http/routes-trading-historical.spec.ts @@ -0,0 +1,63 @@ +/** + * `POST /uta/:id/historical` wire shape. Dropping `session`/`forced` on the way + * out would leave the client unable to tell a regular series from a continuous + * one. + */ + +import { describe, expect, it, vi } from 'vitest' +import { createTradingRoutes } from './routes-trading.js' +import type { UTAEngineContext } from '../types.js' + +function makeRoutes(uta: unknown) { + const ctx = { + utaManager: { get: (id: string) => (id === 'mock-uta' ? uta : undefined) }, + snapshotService: undefined, + } as unknown as UTAEngineContext + return createTradingRoutes(ctx) +} + +const BARS = [{ timestamp: new Date('2026-09-03T00:00:00.000Z'), open: '1', high: '2', low: '0.5', close: '1.5', volume: '10' }] + +async function post(app: ReturnType, body: unknown) { + return app.request('/uta/mock-uta/historical', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('POST /uta/:id/historical', () => { + it('returns bars alongside the effective session and forced flag', async () => { + const getHistorical = vi.fn(async (_contract: unknown, _params: Record) => ({ bars: BARS, session: 'regular', forced: false })) + const res = await post(makeRoutes({ getHistorical }), { contract: { aliceId: 'mock-uta|AAPL' }, params: { interval: '1d', limit: 1 } }) + expect(res.status).toBe(200) + const body = await res.json() as { bars: unknown[]; session: string; forced: boolean } + expect(body.bars).toHaveLength(1) + expect(body.session).toBe('regular') + expect(body.forced).toBe(false) + }) + + it('surfaces a forced session rather than echoing the request', async () => { + const getHistorical = vi.fn(async (_contract: unknown, _params: Record) => ({ bars: [], session: 'extended', forced: true })) + const res = await post(makeRoutes({ getHistorical }), { contract: { aliceId: 'mock-uta|EURUSD' }, params: { interval: '1h', session: 'regular' } }) + await expect(res.json()).resolves.toMatchObject({ session: 'extended', forced: true }) + expect(getHistorical.mock.calls[0]![1]).toMatchObject({ session: 'regular' }) + }) + + it('revives start/end to Date and forwards the requested session', async () => { + const getHistorical = vi.fn(async (_contract: unknown, _params: Record) => ({ bars: [], session: 'extended', forced: false })) + await post(makeRoutes({ getHistorical }), { + contract: { aliceId: 'mock-uta|AAPL' }, + params: { interval: '1h', start: '2026-09-01T00:00:00.000Z', end: '2026-09-03T00:00:00.000Z', session: 'extended' }, + }) + const params = getHistorical.mock.calls[0]![1] as unknown as { start: Date; end: Date; session: string } + expect(params.start).toBeInstanceOf(Date) + expect(params.end).toBeInstanceOf(Date) + expect(params.session).toBe('extended') + }) + + it('404s for an unknown account', async () => { + const res = await makeRoutes({}).request('/uta/nope/historical', { method: 'POST', body: '{}' }) + expect(res.status).toBe(404) + }) +}) diff --git a/services/uta/src/http/routes-trading.ts b/services/uta/src/http/routes-trading.ts index 11f39b5bb2..35c8e9e3fd 100644 --- a/services/uta/src/http/routes-trading.ts +++ b/services/uta/src/http/routes-trading.ts @@ -36,6 +36,9 @@ const placeOrderSchema = z.object({ outsideRth: z.boolean().optional(), parentId: z.string().optional(), ocaGroup: z.string().optional(), + // Zod strips unknown keys, so a field omitted here never reaches staging and + // the order goes out with the broker default instead. + ocaType: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), takeProfit: z.object({ price: numericString }).optional(), stopLoss: z.object({ price: numericString, limitPrice: numericString.optional() }).optional(), subAccountId: z.string().optional(), @@ -353,8 +356,8 @@ export function createTradingRoutes(ctx: UTAEngineContext) { const params = { ...(body.params ?? {}) } if (params.start) params.start = new Date(params.start) if (params.end) params.end = new Date(params.end) - const bars = await account.getHistorical(contract, params) - return c.json({ bars }) + // The effective session is part of the answer, not a request echo. + return c.json(await account.getHistorical(contract, params)) } catch (err) { return c.json({ error: err instanceof Error ? err.message : String(err) }, 500) } diff --git a/src/domain/analysis/snapshot.ts b/src/domain/analysis/snapshot.ts index 88df8be2d1..a1a18d5b47 100644 --- a/src/domain/analysis/snapshot.ts +++ b/src/domain/analysis/snapshot.ts @@ -19,6 +19,7 @@ */ import type { BarService, BarSourceRef, BarCapability, BarSourceKind } from '@/domain/market-data/bars/index' +import type { BarSession } from '@traderalice/uta-protocol' import { SMA } from './indicator/functions/statistics.js' import { RSI } from './indicator/functions/technical.js' @@ -32,6 +33,8 @@ export interface SnapshotOpts { /** How many recent dated bars to RETURN in `bars` (default 0 = summary only — * the dated path is opt-in so a "how's X" read stays light). Capped at count. */ barsOut?: number + /** Trading session for broker sources. Omit for the per-instrument default. */ + session?: BarSession } export interface SnapshotBar { @@ -49,6 +52,10 @@ export interface SnapshotResult { source?: BarSourceKind barCapability?: BarCapability interval: string + /** The session the bars actually cover (broker sources only). */ + session?: BarSession + /** True when the requested session could not be honored. */ + sessionForced?: boolean /** Effective anchor. */ asOf: string /** LOUD freshness: did the data reach `asOf`? */ @@ -106,6 +113,7 @@ export async function getSnapshot( interval, count, ...(opts.asOf ? { end: opts.asOf, asOf: opts.asOf } : {}), + ...(opts.session ? { session: opts.session } : {}), }) const asOf = meta.asOf ?? opts.asOf ?? new Date().toISOString().slice(0, 10) @@ -121,6 +129,8 @@ export async function getSnapshot( source: meta.source, barCapability: meta.barCapability, interval, + ...(meta.session ? { session: meta.session } : {}), + ...(meta.sessionForced ? { sessionForced: true } : {}), asOf, isLatestActual, staleTradingDays, diff --git a/src/domain/market-data/bars/bar-service.spec.ts b/src/domain/market-data/bars/bar-service.spec.ts index 1739f5d03b..5cc4d0fc8a 100644 --- a/src/domain/market-data/bars/bar-service.spec.ts +++ b/src/domain/market-data/bars/bar-service.spec.ts @@ -8,7 +8,7 @@ import type { BarServiceDeps, UtaBarGateway } from './types.js' import type { EquityClientLike, CryptoClientLike, CurrencyClientLike, CommodityClientLike, } from '../client/types.js' -import type { Bar } from '@traderalice/uta-protocol' +import type { Bar, BarParams, HistoricalBarsResult } from '@traderalice/uta-protocol' // One unsorted batch with a null-OHLC row that must be filtered out. const RAW = [ @@ -141,9 +141,12 @@ describe('getBars — UTA branch', () => { { timestamp: '2024-02-02T00:00:00.000Z', open: '2', high: '3', low: '1', close: '2.5', volume: '200' }, { timestamp: '2024-02-01T00:00:00.000Z', open: '1', high: '2', low: '0.5', close: '1.5', volume: '100' }, ] as unknown as Bar[] + /** UTA reports the session it actually served, not an echo of the request. */ + const result = (bars: Bar[] = WIRE, extra: Partial = {}): HistoricalBarsResult => + ({ bars, session: 'extended', forced: false, ...extra }) it('discriminates uta via gateway, converts string→number, tags realtime (Date arrives as string over the wire)', async () => { - const getHistorical = vi.fn(async () => WIRE) + const getHistorical = vi.fn(async () => result()) const utaManager: UtaBarGateway = { has: async (id) => id === 'alpaca-paper', get: async () => ({ getHistorical }), @@ -162,7 +165,7 @@ describe('getBars — UTA branch', () => { it('reports the broker\'s HONEST bar quality (alpaca free = iex), not a blanket realtime', async () => { const utaManager: UtaBarGateway = { has: async (id) => id === 'alpaca-paper', - get: async () => ({ getHistorical: async () => WIRE }), + get: async () => ({ getHistorical: async () => result() }), searchContracts: async () => [], getBarCapabilities: async () => ({ 'alpaca-paper': 'iex' }), } @@ -174,7 +177,7 @@ describe('getBars — UTA branch', () => { it('falls back to realtime when the gateway cannot surface a quality', async () => { const utaManager: UtaBarGateway = { has: async (id) => id === 'alpaca-paper', - get: async () => ({ getHistorical: async () => WIRE }), + get: async () => ({ getHistorical: async () => result() }), searchContracts: async () => [], // no getBarCapabilities } @@ -184,7 +187,7 @@ describe('getBars — UTA branch', () => { }) it('rejects a UTA source that does not advertise historical-bar support', async () => { - const getHistorical = vi.fn(async () => WIRE) + const getHistorical = vi.fn(async () => result()) const utaManager: UtaBarGateway = { has: async (id) => id === 'ibkr', get: async () => ({ getHistorical }), @@ -207,7 +210,7 @@ describe('getBars — UTA branch', () => { ] as unknown as Bar[] const utaManager: UtaBarGateway = { has: async (id) => id === 'alpaca-paper', - get: async () => ({ getHistorical: async () => dailyWire }), + get: async () => ({ getHistorical: async () => result(dailyWire) }), searchContracts: async () => [], } const svc = createBarService(makeDeps({ utaManager })) @@ -221,7 +224,7 @@ describe('getBars — UTA branch', () => { // upstream APIs that otherwise return the first N rows from `start`. const getHistorical = vi.fn(async (_ref: unknown, params: { start?: Date; limit?: number }) => { void params - return WIRE + return result() }) const utaManager: UtaBarGateway = { has: async (id) => id === 'alpaca-paper', @@ -234,6 +237,43 @@ describe('getBars — UTA branch', () => { expect(params.start).toBeInstanceOf(Date) expect(params.limit).toBe(60) }) + + it('stamps the session UTA actually served onto the meta', async () => { + const utaManager: UtaBarGateway = { + has: async (id) => id === 'ibkr', + get: async () => ({ getHistorical: async () => result(WIRE, { session: 'regular' }) }), + searchContracts: async () => [], + } + const svc = createBarService(makeDeps({ utaManager })) + const { meta } = await svc.getBars({ barId: 'ibkr|AAPL' }, { interval: '1d' }) + expect(meta.session).toBe('regular') + expect(meta.sessionForced).toBeUndefined() + }) + + it('surfaces a forced session so a caller never mistakes it for what it asked', async () => { + const utaManager: UtaBarGateway = { + has: async (id) => id === 'alpaca-paper', + get: async () => ({ getHistorical: async () => result(WIRE, { session: 'extended', forced: true }) }), + searchContracts: async () => [], + } + const svc = createBarService(makeDeps({ utaManager })) + const { meta } = await svc.getBars({ barId: 'alpaca-paper|AAPL' }, { interval: '1d', session: 'regular' }) + expect(meta).toMatchObject({ session: 'extended', sessionForced: true }) + }) + + it('forwards an explicit session request to the broker and omits it otherwise', async () => { + const getHistorical = vi.fn(async (_ref: unknown, _params: BarParams) => result()) + const utaManager: UtaBarGateway = { + has: async (id) => id === 'ibkr', + get: async () => ({ getHistorical }), + searchContracts: async () => [], + } + const svc = createBarService(makeDeps({ utaManager })) + await svc.getBars({ barId: 'ibkr|AAPL' }, { interval: '1d', session: 'regular' }) + expect(getHistorical.mock.calls[0][1]).toMatchObject({ session: 'regular' }) + await svc.getBars({ barId: 'ibkr|AAPL' }, { interval: '1d' }) + expect(getHistorical.mock.calls[1][1]).not.toHaveProperty('session') + }) }) describe('searchBarSources — federated candidates', () => { diff --git a/src/domain/market-data/bars/bar-service.ts b/src/domain/market-data/bars/bar-service.ts index 1d7526e049..2d7670417e 100644 --- a/src/domain/market-data/bars/bar-service.ts +++ b/src/domain/market-data/bars/bar-service.ts @@ -258,8 +258,11 @@ export function createBarService(deps: BarServiceDeps): BarService { start: start ? new Date(start) : undefined, end: (opts.end ?? opts.asOf) ? new Date((opts.end ?? opts.asOf)!) : undefined, limit: opts.count, + ...(opts.session ? { session: opts.session } : {}), } - const wireBars = await acct.getHistorical({ aliceId: barId }, params) + // UTA reports the session it actually served, so a consumer can tell a + // regular series from a continuous one. + const { bars: wireBars, session, forced } = await acct.getHistorical({ aliceId: barId }, params) const bars = finalize(wireBars.map((b) => barToOhlcv(b, params.interval)), opts.count) const symbol = parseBarId(barId)?.nativeSymbol ?? barId return { @@ -269,6 +272,8 @@ export function createBarService(deps: BarServiceDeps): BarService { sourceId, barId, barCapability: effectiveCap, + ...(session ? { session } : {}), + ...(forced ? { sessionForced: true } : {}), ...computeFreshness(bars[bars.length - 1]?.date ?? '', opts, () => new Date()), }), } diff --git a/src/domain/market-data/bars/types.ts b/src/domain/market-data/bars/types.ts index 5832bbee9f..c4a531c892 100644 --- a/src/domain/market-data/bars/types.ts +++ b/src/domain/market-data/bars/types.ts @@ -17,7 +17,7 @@ * structurally-identical `OhlcvData`/`DataSourceMeta` for free. */ -import type { Bar, BarParams, ContractSearchHit } from '@traderalice/uta-protocol' +import type { BarParams, BarSession, ContractSearchHit, HistoricalBarsResult } from '@traderalice/uta-protocol' import type { AssetClass, MarketSearchDeps } from '../aggregate-search.js' import type { EquityClientLike, @@ -89,6 +89,12 @@ export interface BarMeta { isLatestActual?: boolean /** Trading-day gap between the last bar and `asOf` (0 when current). */ staleTradingDays?: number + // ---- session contract (broker sources only) ---- + /** Which trading session these bars actually cover. Absent for vendor feeds, + * which expose no session filter. */ + session?: BarSession + /** True when the requested session could not be honored. */ + sessionForced?: boolean } export interface BarSourceCandidate { @@ -121,6 +127,9 @@ export interface GetBarsOpts { end?: string /** Point-in-time anchor for `count` (alias of `end`; default now). */ asOf?: string + /** Trading session for broker (UTA) sources; vendor sources ignore it. Omit + * to take the per-instrument default. */ + session?: BarSession } /** @@ -141,7 +150,7 @@ export interface BarService { /** Minimal broker-bar account surface (UTAAccountSDK satisfies it structurally). */ export interface UtaBarAccount { - getHistorical(query: { aliceId?: string }, params: BarParams): Promise + getHistorical(query: { aliceId?: string }, params: BarParams): Promise } /** Minimal broker-bar gateway (UTAManagerSDK satisfies it structurally). */ diff --git a/src/services/uta-client/UTAAccountSDK.ts b/src/services/uta-client/UTAAccountSDK.ts index 3dc5be12e1..e852d59ab4 100644 --- a/src/services/uta-client/UTAAccountSDK.ts +++ b/src/services/uta-client/UTAAccountSDK.ts @@ -19,8 +19,8 @@ import type { Position, OpenOrder, Quote, - Bar, BarParams, + HistoricalBarsResult, MarketClock, BrokerHealth, BrokerHealthInfo, @@ -181,17 +181,18 @@ export class UTAAccountSDK { * `getHistorical` land in Phase 1; until then this 404s at runtime (no * vendor flow calls it). `Date` fields serialize to ISO strings over the * wire; the route revives them. + * + * Returns the bars plus the session UTA actually served and whether the + * request had to be overridden. */ getHistorical( query: Contract | (Partial & { aliceId?: string }), params: BarParams, - ): Promise { - return this.client - .post<{ bars: Bar[] }>( - `/api/trading/uta/${encodeURIComponent(this.id)}/historical`, - { contract: query, params }, - ) - .then((r) => r.bars) + ): Promise { + return this.client.post( + `/api/trading/uta/${encodeURIComponent(this.id)}/historical`, + { contract: query, params }, + ) } searchContracts(pattern: string): Promise { diff --git a/src/tool/snapshot.ts b/src/tool/snapshot.ts index dfb66cd4f2..8bc3c6e070 100644 --- a/src/tool/snapshot.ts +++ b/src/tool/snapshot.ts @@ -36,9 +36,10 @@ FRESHNESS IS LOAD-BEARING. The result carries asOf / isLatestActual / staleTradi asOf: z.string().optional().describe('Point-in-time YYYY-MM-DD. Bars never run past it (no lookahead). Default: now.'), interval: z.string().optional().describe('Bar interval (default "1d").'), count: z.number().int().positive().optional().describe('Analysis window fetched for the levels (default 90; sma50 needs ≥50). NOT the output size.'), + session: z.enum(['regular', 'extended']).optional().describe('Default regular hours for stocks/options, continuous for FX/futures/crypto; extended opts in to pre/post/overnight. FX and crypto are always continuous. The response stamps the effective session.'), bars: z.number().int().nonnegative().optional().describe('How many recent dated bars to RETURN in `bars` (default 0 = summary only — latest + levels + freshness). Set e.g. 30/90 when you need the dated path (it can be large). `windowBars` tells how many are available.'), }).meta({ examples: [{ query: 'XLE' }, { query: 'NVDA', asOf: '2026-04-15', bars: 30 }] }), - execute: async ({ query, barId, asset, asOf, interval, count, bars }) => { + execute: async ({ query, barId, asset, asOf, interval, count, bars, session }) => { const resolved = await resolveBarSource(barService, { query, barId, asset }) if ('error' in resolved) return resolved const snap = await getSnapshot(barService, resolved.ref as never, { @@ -46,6 +47,7 @@ FRESHNESS IS LOAD-BEARING. The result carries asOf / isLatestActual / staleTradi ...(interval ? { interval } : {}), ...(count ? { count } : {}), ...(bars != null ? { barsOut: bars } : {}), + ...(session ? { session } : {}), }) return resolved.pickedFrom ? { ...snap, autoPickedSource: resolved.pickedFrom } : snap }, diff --git a/src/tool/trading-compact.ts b/src/tool/trading-compact.ts index 4bc3202ec2..52c7ec4554 100644 --- a/src/tool/trading-compact.ts +++ b/src/tool/trading-compact.ts @@ -108,6 +108,13 @@ export function compactOrderFields(o: unknown): AnyRec { pick(out, 'tif', val(k['tif'])) pick(out, 'goodTillDate', val(k['goodTillDate'])) if (k['outsideRth'] === true) out['outsideRth'] = true + // Linkage is risk-relevant: without it an order row reads as standalone. + // `0`/`''` are IBKR's unset values, so only truthy values carry signal. + pick(out, 'ocaGroup', val(k['ocaGroup'])) + const ocaType = val(k['ocaType']) + if (ocaType && ocaType !== '0') out['ocaType'] = ocaType + const parentId = val(k['parentId']) + if (parentId && parentId !== '0') out['parentId'] = parentId const filled = val(k['filledQuantity']) if (filled) out['filledQuantity'] = filled return out diff --git a/src/tool/trading.spec.ts b/src/tool/trading.spec.ts index 55a94ddd4b..f6463c16c1 100644 --- a/src/tool/trading.spec.ts +++ b/src/tool/trading.spec.ts @@ -317,3 +317,14 @@ describe('tradingPush — AI-trading gate (#95)', () => { expect(pushed()).toBe(0) }) }) + +describe('ocaType input', () => { + it('accepts CLI string values, keeps numbers, rejects 0', () => { + const tools = createTradingTools(fakeManager([])) + const schema = (tools.modifyOrder as unknown as { inputSchema: { parse(v: unknown): { ocaType?: number }; safeParse(v: unknown): { success: boolean } } }).inputSchema + expect(schema.parse({ source: 'ibkr', orderId: '1', ocaType: '2' }).ocaType).toBe(2) + expect(schema.parse({ source: 'ibkr', orderId: '1', ocaType: 3 }).ocaType).toBe(3) + expect(schema.safeParse({ source: 'ibkr', orderId: '1', ocaType: '0' }).success).toBe(false) + expect(schema.safeParse({ source: 'ibkr', orderId: '1', ocaType: 4 }).success).toBe(false) + }) +}) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index ada52495f6..f6e96032e3 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -104,6 +104,16 @@ function summarizeOrder(o: OpenOrder, source: string, stringOrderId?: string) { } } +/** + * IBKR OCA semantics. `0` is IBKR's unset value and TWS silently ignores a + * group with type 0, so it is not accepted here; omit the field for the default. + */ +const ocaTypeSchema = z.preprocess( + // CLI flags arrive as strings (`--oca-type 1`). + (value) => (typeof value === 'string' && /^[123]$/.test(value.trim()) ? Number(value.trim()) : value), + z.union([z.literal(1), z.literal(2), z.literal(3)]), +).describe('OCA semantics for ocaGroup: 1 = CANCEL_WITH_BLOCK (cancel the siblings, block partial overfill), 2 = REDUCE_WITH_BLOCK (reduce sibling size, block overfill), 3 = REDUCE_NON_BLOCK (reduce sibling size, allow overfill). Omit for the broker default (IBKR: 1).') + const sourceDesc = (required: boolean, extra?: string) => { const base = `Account source — matches account id (e.g. "alpaca-paper") or provider (e.g. "alpaca", "ccxt").` const req = required @@ -642,7 +652,7 @@ Required params by orderType: TRAIL: totalQuantity + auxPrice (trailing offset) or trailingPercent TRAIL LIMIT: totalQuantity + auxPrice (trailing offset) + lmtPrice MOC: totalQuantity -Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, +Optional: attach takeProfit and/or stopLoss for automatic exit orders (they get their own OCA group, so do not also pass ocaGroup).`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false, 'Defaults to the account inside aliceId.')), aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), @@ -659,7 +669,8 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, goodTillDate: z.string().optional().describe('Expiration datetime for GTD orders'), outsideRth: z.boolean().optional().describe('Allow execution outside regular trading hours'), parentId: z.string().optional().describe('Parent order ID (bracket orders)'), - ocaGroup: z.string().optional().describe('One-Cancels-All group name'), + ocaGroup: z.string().optional().describe('One-Cancels-All group name. Placement time is the ONLY reliable way to establish OCA membership — it cannot be added to a working order later (see modifyOrder). Set the same group on every leg as you place it. Mutually exclusive with takeProfit/stopLoss: a bracket mints its own group for its exit legs. IBKR-only: other brokers refuse rather than place an unlinked order.'), + ocaType: ocaTypeSchema.optional(), takeProfit: z.object({ price: z.string().describe('Take profit price'), }).optional().describe('Take profit order (single-level, full quantity)'), @@ -692,8 +703,11 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, orderType: z.enum(['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC']).optional().describe('New order type'), tif: z.enum(['DAY', 'GTC', 'IOC', 'FOK', 'OPG', 'GTD']).optional().describe('New time in force'), goodTillDate: z.string().optional().describe('New expiration date'), + ocaGroup: z.string().optional().describe('One-Cancels-All group name. NOT revisable on a working order at IBKR: the venue rejects it with error 10327 ("OCA group type revision is not allowed"), and omitting ocaType makes TWS accept the re-place while SILENTLY dropping the group. Recipe instead: place the new leg with ocaGroup set at placement time, then cancel the old protective order and re-place it with the same ocaGroup (order the two steps so protection stays continuous). Kept on the schema for brokers that may support in-place linkage; IBKR refuses it.'), + ocaType: ocaTypeSchema.optional().describe('OCA semantics. Same restriction as ocaGroup: IBKR rejects an OCA revision on a working order (10327). Set it at placement time.'), + parentId: z.string().optional().describe('Re-parent the order (bracket attachment). Same restriction as ocaGroup: IBKR does not allow re-parenting a working order — cancel and re-place with parentId at placement time.'), commitMessage: z.string().optional().describe('Stage AND commit in one step with this message. Push/approval still required.'), - }).meta({ examples: [{ source: 'alpaca-paper', orderId: '1', lmtPrice: '150' }] }), + }).meta({ examples: [{ source: 'ibkr-paper', orderId: '17', lmtPrice: '182.50', commitMessage: 'Tighten the resting stop-limit' }] }), execute: async ({ source, commitMessage, ...params }) => { const uta = await manager.resolveOne(source) return { diff --git a/src/webui/routes/bars.spec.ts b/src/webui/routes/bars.spec.ts index 94c5013120..eb7ae420f5 100644 --- a/src/webui/routes/bars.spec.ts +++ b/src/webui/routes/bars.spec.ts @@ -52,6 +52,37 @@ describe('bars routes', () => { ) }) + it('GET / forwards an explicit session to the bar service', async () => { + const ctx = mkCtx() + const getBars = vi.spyOn(ctx.barService, 'getBars') + const res = await createBarsRoutes(ctx).request('/?barId=alpaca-paper|AAPL&interval=1m&session=extended') + + expect(res.status).toBe(200) + expect(getBars).toHaveBeenCalledWith( + { barId: 'alpaca-paper|AAPL' }, + { interval: '1m', session: 'extended' }, + ) + }) + + it('GET / omits session when the caller does not ask for one', async () => { + const ctx = mkCtx() + const getBars = vi.spyOn(ctx.barService, 'getBars') + await createBarsRoutes(ctx).request('/?barId=yfinance|AAPL&interval=1d') + + expect(getBars).toHaveBeenCalledWith({ barId: 'yfinance|AAPL' }, { interval: '1d' }) + }) + + it('GET / with an unknown session → 400 without touching the bar service', async () => { + const ctx = mkCtx() + const getBars = vi.spyOn(ctx.barService, 'getBars') + const res = await createBarsRoutes(ctx).request('/?barId=yfinance|AAPL&interval=1d&session=premarket') + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error).toMatch(/regular.*extended/) + expect(getBars).not.toHaveBeenCalled() + }) + it('GET / without barId or symbol → 400', async () => { const res = await createBarsRoutes(mkCtx()).request('/?interval=1d') expect(res.status).toBe(400) diff --git a/src/webui/routes/bars.ts b/src/webui/routes/bars.ts index a53978bee3..4df353ef81 100644 --- a/src/webui/routes/bars.ts +++ b/src/webui/routes/bars.ts @@ -14,6 +14,8 @@ import type { EngineContext } from '../../core/types.js' import type { BarSourceRef, GetBarsOpts } from '../../domain/market-data/bars/index.js' import type { AssetClass } from '../../domain/market-data/aggregate-search.js' +const BAR_SESSIONS = ['regular', 'extended'] as const + export function createBarsRoutes(ctx: EngineContext): Hono { const app = new Hono() @@ -28,7 +30,7 @@ export function createBarsRoutes(ctx: EngineContext): Hono { return c.json({ candidates, count: candidates.length }) }) - // GET /api/bars?barId=&interval=&count=&start=&end=&assetClass= + // GET /api/bars?barId=&interval=&count=&start=&end=&assetClass=&session= // or ?symbol=&assetClass=&interval= (vendor-default, when no barId chosen yet) app.get('/', async (c) => { const interval = c.req.query('interval') ?? '1d' @@ -38,6 +40,11 @@ export function createBarsRoutes(ctx: EngineContext): Hono { const count = c.req.query('count') const start = c.req.query('start') const end = c.req.query('end') + const sessionParam = c.req.query('session') + const session = BAR_SESSIONS.find((value) => value === sessionParam) + if (sessionParam && !session) { + return c.json({ results: null, meta: null, error: "session must be 'regular' or 'extended'" }, 400) + } let ref: BarSourceRef if (barId) ref = assetClass ? { barId, assetClass } : { barId } @@ -48,6 +55,7 @@ export function createBarsRoutes(ctx: EngineContext): Hono { if (count) opts.count = Number(count) if (start) opts.start = start if (end) opts.end = end + if (session) opts.session = session try { const { bars, meta } = await ctx.barService.getBars(ref, opts) diff --git a/ui/src/api/market.ts b/ui/src/api/market.ts index f8d0b17403..1705c40b61 100644 --- a/ui/src/api/market.ts +++ b/ui/src/api/market.ts @@ -157,6 +157,8 @@ export interface BarSourceCandidate { } /** Provenance of the bars currently shown — the explicit "who provided this". */ +export type BarSession = 'regular' | 'extended' + export interface BarMeta { symbol: string from: string @@ -167,6 +169,11 @@ export interface BarMeta { barId: string provider: string barCapability?: BarCapability + /** Session the bars actually cover. Absent for vendor feeds, which expose no + * session filter. */ + session?: BarSession + /** True when the requested session could not be honored. */ + sessionForced?: boolean } export interface BarsResponse { @@ -191,6 +198,7 @@ export const barsApi = { count?: number start?: string end?: string + session?: BarSession }): Promise { const qs = new URLSearchParams({ interval: params.interval }) if (params.barId) qs.set('barId', params.barId) @@ -199,6 +207,7 @@ export const barsApi = { if (params.count != null) qs.set('count', String(params.count)) if (params.start) qs.set('start', params.start) if (params.end) qs.set('end', params.end) + if (params.session) qs.set('session', params.session) return fetchJson(`/api/bars?${qs}`) }, } diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 9b6bb7fd46..e5755b6e4b 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -640,6 +640,8 @@ export interface PlaceOrderRequest { outsideRth?: boolean parentId?: string ocaGroup?: string + /** OCA semantics: 1 CANCEL_WITH_BLOCK, 2 REDUCE_WITH_BLOCK, 3 REDUCE_NON_BLOCK. */ + ocaType?: number takeProfit?: { price: string } stopLoss?: { price: string; limitPrice?: string } /** Target wallet on multi-wallet venues — required when the account spans >1. */ diff --git a/ui/src/demo/handlers/market.ts b/ui/src/demo/handlers/market.ts index 01cca6f4a2..268975bee1 100644 --- a/ui/src/demo/handlers/market.ts +++ b/ui/src/demo/handlers/market.ts @@ -132,10 +132,12 @@ export const marketHandlers = [ }) })() const sourceId = barId ? barId.split('|')[0] : 'yfinance' + const session = url.searchParams.get('session') const meta: BarMeta = { symbol: selected.symbol, from: results[0]?.date ?? '', to: results[results.length - 1]?.date ?? '', bars: results.length, source: sourceId === 'alpaca-paper' ? 'uta' : 'vendor', sourceId, barId: barId ?? `${sourceId}|${selected.symbol}`, provider: sourceId, barCapability: sourceId === 'alpaca-paper' ? 'iex' : 'delayed', + ...(session === 'regular' || session === 'extended' ? { session, sessionForced: false } : {}), } return HttpResponse.json({ results, meta }) }), From 157b3420c0550f68f0f12009481dc8b0433f4bed Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:01:56 -0700 Subject: [PATCH 5/9] fix(uta): keep broker-held orders working in the ledger TWS uses Inactive for BOTH a reject and a legitimate hold: an OCA sibling parked behind its partner, a transmit=false bracket parent, an exchange-closed or precautionary hold. A real reject arrives as error() and rejects the pending order promise, so anything that resolves with success: true and Inactive was accepted by the venue. TradingGit.mapOrderStatus mapped Inactive to rejected on the success path, where no error field exists, and UnifiedTradingAccount.sync treated every status other than Submitted/PreSubmitted as terminal. Both wrote a reason-less rejection, and since rejected is terminal the order fell out of order-sync for good. On the live account this produced seven MU target legs reported rejected with no text; all seven were working at IBKR and the eighth came back as IBKR error 201 (15 working orders per side). Held is working: Inactive stays in the pending lane and sync reconciles it. Brokers whose terminal reject is a plain status (Alpaca, CCXT, Longbridge, LeverUp) now report Rejected instead of Inactive, and a rejection with an empty broker message records 'Unknown error' rather than an empty string. "Is this order still working" is then answered the same way everywhere. WORKING_ORDER_STATUSES arrived for order-sync but a second, narrower predicate stayed behind. `PendingCancel` was missing from the set: it means the cancel request has been sent and the venue has not confirmed it, so the order can still fill, yet sync folded it into the terminal `rejected` branch and wrote the same message-less ledger row that `Inactive` was just fixed to avoid. The GitState snapshot still filtered `pendingOrders` on Submitted/PreSubmitted, so a held order stayed in the ledger's pending lane and was chased by sync while being absent from the persisted state and from `pendingOrderCount`. It now reuses the constant. Longbridge has the same shape under a different status. It reports US-equity GTC limit orders as Expired (status 16) between sessions and reverts them to New at the open. Mapping every Expired to a terminal status dropped those orders from order-sync, so a later fill or the still-open order was invisible to UTA. Disambiguate on the order's time in force: Expired with GTC stays Submitted and keeps polling; Day and GTD stay terminal, since a GTD parking has not been observed. Evidence and the disambiguation come from upstream #1125 by FaintGhost. --- .../trading/UnifiedTradingAccount.spec.ts | 71 +++++++++++++++++++ .../domain/trading/UnifiedTradingAccount.ts | 24 ++++++- .../brokers/alpaca/alpaca-contracts.ts | 4 +- .../trading/brokers/ccxt/ccxt-contracts.ts | 4 +- .../longbridge/LongbridgeBroker.spec.ts | 8 ++- .../brokers/longbridge/LongbridgeBroker.ts | 2 +- .../longbridge/longbridge-contracts.ts | 16 +++-- .../brokers/others/leverup/LeverupBroker.ts | 6 +- .../src/domain/trading/git/TradingGit.spec.ts | 44 ++++++++++-- .../uta/src/domain/trading/git/TradingGit.ts | 17 +++-- 10 files changed, 172 insertions(+), 24 deletions(-) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts index ee807164f2..ddabd87635 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -372,6 +372,24 @@ describe('UTA — getState', () => { expect(spyOrders).toHaveBeenCalledTimes(1) }) + it('keeps every working status in pendingOrders and drops only the terminal ones', async () => { + const withStatus = (symbol: string, status: string) => { + const orderState = new OrderState() + orderState.status = status + return makeOpenOrder({ contract: makeContract({ symbol }), orderState }) + } + vi.spyOn(broker, 'getOrders').mockResolvedValue([ + withStatus('HELD', 'Inactive'), + withStatus('CANCELING', 'PendingCancel'), + withStatus('GONE', 'Cancelled'), + withStatus('DONE', 'Filled'), + ]) + + const state = await uta.getState() + + expect(state.pendingOrders.map((o) => o.contract.symbol)).toEqual(['HELD', 'CANCELING']) + }) + it('returns empty pendingOrders when no orders are pending', async () => { const filledState = new OrderState() filledState.status = 'Filled' @@ -948,6 +966,59 @@ describe('UTA — sync', () => { expect(result.updates[0].filledPrice).toBe('149.2') }) + // `Inactive` is TWS's held state for an OCA sibling or bracket leg, not a + // reject. + it('does NOT mark a held (Inactive) order rejected — it stays working and reconcilable', async () => { + const { uta, broker } = createUTA() + + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: '10', lmtPrice: '150' }) + uta.commit('OCA target leg') + const orderId = (await uta.push(uta.status().pendingHash!)).submitted[0]!.orderId! + + // TWS parks held OCA legs out of the open-order listing, so only the + // confirming getOrder reports them. + ;(broker as unknown as { getOpenOrders: () => Promise }).getOpenOrders = async () => [] + const heldState = new OrderState() + heldState.status = 'Inactive' + vi.spyOn(broker, 'getOrder').mockResolvedValue({ + contract: makeContract({ symbol: 'AAPL' }), + order: new Order(), + orderState: heldState, + orderId, + } as never) + + const result = await uta.sync() + + expect(result.updates.map((u) => u.currentStatus)).not.toContain('rejected') + expect(result.updatedCount).toBe(0) + // Still polled next pass: a held order is not terminal. + expect(uta.getPendingOrderIds().map((p) => p.orderId)).toContain(orderId) + }) + + it('does NOT mark a PendingCancel order rejected — an unconfirmed cancel can still fill', async () => { + const { uta, broker } = createUTA() + + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: '10', lmtPrice: '150' }) + uta.commit('limit buy') + const orderId = (await uta.push(uta.status().pendingHash!)).submitted[0]!.orderId! + + ;(broker as unknown as { getOpenOrders: () => Promise }).getOpenOrders = async () => [] + const cancellingState = new OrderState() + cancellingState.status = 'PendingCancel' + vi.spyOn(broker, 'getOrder').mockResolvedValue({ + contract: makeContract({ symbol: 'AAPL' }), + order: new Order(), + orderState: cancellingState, + orderId, + } as never) + + const result = await uta.sync() + + expect(result.updates.map((u) => u.currentStatus)).not.toContain('rejected') + expect(result.updatedCount).toBe(0) + expect(uta.getPendingOrderIds().map((p) => p.orderId)).toContain(orderId) + }) + it('listing mode: getOrder is spent ONLY on orders absent from the open-orders listing', async () => { const { uta, broker } = createUTA() diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index cb4e218c3b..ddeab2e8c6 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -12,6 +12,20 @@ import { Contract, Order, ContractDescription, ContractDetails, UNSET_DECIMAL, U import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo, type BrokerConnectionStateEvent, type UTAReach, type UTATier, type TpSlParams, type Bar, type BarParams, type ExpandContractFilters, type ContractExpansion, type SubAccountRef } from './brokers/types.js' const REACH_RANK: Record = { down: 0, connected: 1, readable: 2 } + +/** + * Broker statuses that mean "still working", which sync keeps in the pending + * lane. `Inactive` is a held order and `PendingCancel` an unconfirmed cancel; + * only `Cancelled` / `ApiCancelled` are terminal. + */ +const WORKING_ORDER_STATUSES = new Set([ + 'Submitted', + 'PreSubmitted', + 'PendingSubmit', + 'PendingCancel', + 'ApiPending', + 'Inactive', +]) import { TradingGit } from './git/TradingGit.js' import { recomputeCostBasisFromCommits } from './cost-basis.js' import { projectOrderHistory, projectTradeHistory } from './order-history.js' @@ -183,7 +197,9 @@ export class UnifiedTradingAccount { unrealizedPnL: accountInfo.unrealizedPnL, realizedPnL: accountInfo.realizedPnL ?? '0', positions, - pendingOrders: orders.filter(o => o.orderState.status === 'Submitted' || o.orderState.status === 'PreSubmitted'), + // Must stay the same predicate as sync: an order pending in one lane + // and terminal in the other can never be reconciled. + pendingOrders: orders.filter(o => WORKING_ORDER_STATUSES.has(o.orderState.status)), } } @@ -940,7 +956,7 @@ export class UnifiedTradingAccount { if (!brokerOrder) continue const status = brokerOrder.orderState.status - if (status !== 'Submitted' && status !== 'PreSubmitted') { + if (!WORKING_ORDER_STATUSES.has(status)) { // Extract fill data when available — `.toFixed()` (not // `.toNumber()`) so sub-satoshi qty (OKX-style accounting) // round-trips into the persisted git operation record without @@ -951,7 +967,9 @@ export class UnifiedTradingAccount { : undefined const currentStatus = - status === 'Filled' ? 'filled' : status === 'Cancelled' ? 'cancelled' : 'rejected' + status === 'Filled' ? 'filled' + : status === 'Cancelled' || status === 'ApiCancelled' ? 'cancelled' + : 'rejected' if (currentStatus === 'filled' && (!filledQty || !brokerOrder.avgFillPrice)) { // Loud, not fatal: a fill without qty/price still advances the // state machine, but cost-basis reconstruction downstream will be diff --git a/services/uta/src/domain/trading/brokers/alpaca/alpaca-contracts.ts b/services/uta/src/domain/trading/brokers/alpaca/alpaca-contracts.ts index 4d32792f8c..0fb43115d6 100644 --- a/services/uta/src/domain/trading/brokers/alpaca/alpaca-contracts.ts +++ b/services/uta/src/domain/trading/brokers/alpaca/alpaca-contracts.ts @@ -58,7 +58,9 @@ export function mapAlpacaOrderStatus(alpacaStatus: string): string { case 'done_for_day': case 'suspended': case 'rejected': - return 'Inactive' + // Not 'Inactive': that is IBKR's held state and order-sync treats it as a + // working order, while these Alpaca statuses are terminal refusals. + return 'Rejected' default: return 'Submitted' } diff --git a/services/uta/src/domain/trading/brokers/ccxt/ccxt-contracts.ts b/services/uta/src/domain/trading/brokers/ccxt/ccxt-contracts.ts index 933a974e55..99e3c5fc0a 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/ccxt-contracts.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/ccxt-contracts.ts @@ -39,8 +39,10 @@ export function mapOrderStatus(status: string | undefined): string { case 'open': return 'Submitted' case 'canceled': case 'cancelled': return 'Cancelled' + // Not 'Inactive': that is IBKR's held state and order-sync keeps such an + // order in the pending lane, while these are terminal. case 'expired': - case 'rejected': return 'Inactive' + case 'rejected': return 'Rejected' default: return 'Submitted' } } diff --git a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts index cdc2fff003..7596955aed 100644 --- a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts @@ -226,10 +226,16 @@ describe('ibkrTifToLb', () => { describe('mapLbOrderStatus', () => { it('Filled → Filled', () => expect(mapLbOrderStatus(5)).toBe('Filled')) - it('Rejected → Inactive', () => expect(mapLbOrderStatus(14)).toBe('Inactive')) + // Not 'Inactive': that is IBKR's held state and order-sync treats it as a + // working order, so a Longbridge reject would never leave the pending lane. + it('Rejected → Rejected', () => expect(mapLbOrderStatus(14)).toBe('Rejected')) it('Canceled → Cancelled', () => expect(mapLbOrderStatus(15)).toBe('Cancelled')) it('PartialFilled → Submitted (still active)', () => expect(mapLbOrderStatus(11)).toBe('Submitted')) it('New → Submitted', () => expect(mapLbOrderStatus(7)).toBe('Submitted')) + it('Expired + GTC → Submitted (parked between sessions)', () => expect(mapLbOrderStatus(16, 'GTC')).toBe('Submitted')) + it('Expired + DAY → Rejected', () => expect(mapLbOrderStatus(16, 'DAY')).toBe('Rejected')) + it('Expired + GTD → Rejected', () => expect(mapLbOrderStatus(16, 'GTD')).toBe('Rejected')) + it('Expired without a tif → Rejected', () => expect(mapLbOrderStatus(16)).toBe('Rejected')) }) // ==================== init() ==================== diff --git a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts index 0e45c0a8af..37e76003dc 100644 --- a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts +++ b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts @@ -709,7 +709,7 @@ export class LongbridgeBroker implements IBroker { const ret: OpenOrder = { contract, order, - orderState: makeOrderState(o.status, o.msg), + orderState: makeOrderState(o.status, o.msg, order.tif), } if (o.executedPrice) ret.avgFillPrice = new Decimal(o.executedPrice.toString()).toString() return ret diff --git a/services/uta/src/domain/trading/brokers/longbridge/longbridge-contracts.ts b/services/uta/src/domain/trading/brokers/longbridge/longbridge-contracts.ts index 2b15b0f0e6..6bc149b5c4 100644 --- a/services/uta/src/domain/trading/brokers/longbridge/longbridge-contracts.ts +++ b/services/uta/src/domain/trading/brokers/longbridge/longbridge-contracts.ts @@ -108,15 +108,19 @@ export function marketToSuffix(market: number): string { /** * Map Longbridge's `OrderStatus` enum (numeric) to IBKR-style status string. - * IBKR statuses we emit: Submitted, Filled, Cancelled, Inactive. + * IBKR statuses we emit: Submitted, Filled, Cancelled, Rejected. Never + * 'Inactive', which order-sync treats as a working order. */ -export function mapLbOrderStatus(status: number): string { +export function mapLbOrderStatus(status: number, tif?: string): string { switch (status) { case 5: // Filled return 'Filled' - case 14: // Rejected case 16: // Expired - return 'Inactive' + // Longbridge parks GTC orders as Expired between sessions and reverts + // them to New at the open (upstream #1125). + return tif === 'GTC' ? 'Submitted' : 'Rejected' + case 14: // Rejected + return 'Rejected' case 15: // Canceled case 17: // PartialWithdrawal return 'Cancelled' @@ -136,9 +140,9 @@ export function mapLbOrderStatus(status: number): string { } /** Make an OrderState from an LB status enum + optional reject message. */ -export function makeOrderState(status: number, msg?: string): OrderState { +export function makeOrderState(status: number, msg?: string, tif?: string): OrderState { const s = new OrderState() - s.status = mapLbOrderStatus(status) + s.status = mapLbOrderStatus(status, tif) if (msg && (status === 14 /* Rejected */)) s.rejectReason = msg return s } diff --git a/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts b/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts index 7763c4e377..e1ea21b00f 100644 --- a/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts +++ b/services/uta/src/domain/trading/brokers/others/leverup/LeverupBroker.ts @@ -73,7 +73,7 @@ interface OrderTrackingRecord { side: 'BUY' | 'SELL' qty: Decimal /** Most-recent known status from relayer. */ - status: 'Submitted' | 'Filled' | 'Cancelled' | 'Inactive' + status: 'Submitted' | 'Filled' | 'Cancelled' | 'Rejected' txnHash?: `0x${string}` reason?: string } @@ -469,7 +469,9 @@ export class LeverupBroker implements IBroker { try { const status = await this.relayer.getStatus(tracked.inputHash) if (status.executed) { - tracked.status = status.success ? 'Filled' : 'Inactive' + // Not 'Inactive': that is IBKR's held state and order-sync keeps a + // held order in the pending lane, while this intent is terminal. + tracked.status = status.success ? 'Filled' : 'Rejected' tracked.txnHash = status.txnHash ?? undefined tracked.reason = status.reason ?? undefined } diff --git a/services/uta/src/domain/trading/git/TradingGit.spec.ts b/services/uta/src/domain/trading/git/TradingGit.spec.ts index 18a0a600e3..de2ce8065f 100644 --- a/services/uta/src/domain/trading/git/TradingGit.spec.ts +++ b/services/uta/src/domain/trading/git/TradingGit.spec.ts @@ -364,7 +364,9 @@ describe('TradingGit', () => { expect(result.submitted[0].status).toBe('submitted') }) - it('maps Inactive orderState to rejected status', async () => { + // A held order is a working order, so `success: true` with orderState + // `Inactive` may not render as a rejection. + it('does NOT map a broker-accepted Inactive (held) order to rejected', async () => { const orderState = new OrderState() orderState.status = 'Inactive' const inactiveConfig = makeConfig({ @@ -377,13 +379,45 @@ describe('TradingGit', () => { const gitInactive = new TradingGit(inactiveConfig) gitInactive.add(buyOp()) - gitInactive.commit('rejected by exchange') + gitInactive.commit('OCA target leg parked behind its sibling stop') const result = await gitInactive.push(gitInactive.status().pendingHash!) - // Inactive maps to rejected — but success is still true from broker - // so it lands in submitted (success-based), with status 'rejected' + expect(result.rejected).toHaveLength(0) expect(result.submitted).toHaveLength(1) - expect(result.submitted[0].status).toBe('rejected') + expect(result.submitted[0].status).toBe('submitted') + expect(result.submitted[0].error).toBeUndefined() + }) + + it('keeps a broker-accepted Inactive order in the order-sync pending lane', async () => { + const orderState = new OrderState() + orderState.status = 'Inactive' + const gitInactive = new TradingGit(makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: '20', + orderState, + }), + })) + + gitInactive.add(buyOp('MU')) + gitInactive.commit('MU protection target leg') + await gitInactive.push(gitInactive.status().pendingHash!) + + // 'rejected' is terminal, and getPendingOrderIds() only polls 'submitted'. + expect(gitInactive.getPendingOrderIds().map((p) => p.orderId)).toContain('20') + }) + + it('never records an empty broker error string as a reason-less rejection', async () => { + const gitEmpty = new TradingGit(makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ success: false, error: '' }), + })) + + gitEmpty.add(buyOp()) + gitEmpty.commit('empty broker error') + const result = await gitEmpty.push(gitEmpty.status().pendingHash!) + + expect(result.rejected).toHaveLength(1) + expect(result.rejected[0].error).toBe('Unknown error') }) it('records failed cancelOrder in rejected array', async () => { diff --git a/services/uta/src/domain/trading/git/TradingGit.ts b/services/uta/src/domain/trading/git/TradingGit.ts index c5d4ccc3a2..f3406d762a 100644 --- a/services/uta/src/domain/trading/git/TradingGit.ts +++ b/services/uta/src/domain/trading/git/TradingGit.ts @@ -956,11 +956,13 @@ export class TradingGit implements ITradingGit { const success = rawObj.success === true if (!success) { + // `||` not `??`: an empty-string error leaves the ledger row reading as a + // bare "rejected" with no reason, which invites a duplicate re-placement. return { action: op.action, success: false, status: 'rejected', - error: (rawObj.error as string) ?? 'Unknown error', + error: (rawObj.error as string) || 'Unknown error', raw, } } @@ -980,12 +982,19 @@ export class TradingGit implements ITradingGit { } } - /** Map IBKR-style OrderState.status to OperationStatus. */ + /** + * Maps IBKR-style OrderState.status to OperationStatus. Only reached on the + * `success: true` path, so `Inactive` is a legitimate hold and must stay in + * the pending lane rather than mapping to `rejected`. + */ private mapOrderStatus(orderState?: { status?: string }): OperationStatus { switch (orderState?.status) { case 'Filled': return 'filled' - case 'Cancelled': return 'cancelled' - case 'Inactive': return 'rejected' + case 'Cancelled': + case 'ApiCancelled': return 'cancelled' + // Non-IBKR adapters surface a terminal venue refusal as 'Rejected' + // rather than 'Inactive', so it needs its own terminal mapping. + case 'Rejected': return 'rejected' default: return 'submitted' } } From 1dd0316e5fea297260ac1034199960f21fb12417 Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:50:28 -0700 Subject: [PATCH 6/9] perf(ibkr): drain reader batches by index instead of shifting per message shift() per dispatched frame made a large inbound burst quadratic in the queue length. Advance a head cursor and drop the dispatched prefix once per batch, keeping the per-turn budget, the setImmediate yield, stop() dropping everything queued, and the decoder-error stop. --- packages/ibkr/src/reader.ts | 13 ++++- packages/ibkr/tests/reader-batching.spec.ts | 63 +++++++++++++++------ 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/packages/ibkr/src/reader.ts b/packages/ibkr/src/reader.ts index 12ca87b666..1518847d5f 100644 --- a/packages/ibkr/src/reader.ts +++ b/packages/ibkr/src/reader.ts @@ -18,6 +18,9 @@ export class EReader { private conn: Connection private buf: Buffer = Buffer.alloc(0) private queue: Buffer[] = [] + /** Read cursor into `queue`. Removing from the head per message would make a + * burst quadratic, so the dispatched prefix is dropped once per batch. */ + private head = 0 private draining = false private stopped = false private dataListener: (() => void) | null = null @@ -53,6 +56,7 @@ export class EReader { this.stopped = true this.draining = false this.queue.length = 0 + this.head = 0 this.buf = Buffer.alloc(0) if (this.dataListener) { this.conn.off('data', this.dataListener) @@ -95,8 +99,10 @@ export class EReader { private drain(): void { let dispatched = 0 - while (this.queue.length > 0) { + while (this.head < this.queue.length) { if (dispatched >= MAX_MESSAGES_PER_TURN) { + this.queue = this.queue.slice(this.head) + this.head = 0 setImmediate(() => { if (this.stopped) { this.draining = false @@ -107,7 +113,8 @@ export class EReader { return } - const msg = this.queue.shift()! + const msg = this.queue[this.head]! + this.head++ dispatched++ try { this.onMessage(msg) @@ -121,6 +128,8 @@ export class EReader { } } + this.queue.length = 0 + this.head = 0 this.draining = false } } diff --git a/packages/ibkr/tests/reader-batching.spec.ts b/packages/ibkr/tests/reader-batching.spec.ts index 2606ecdacc..a4191c001a 100644 --- a/packages/ibkr/tests/reader-batching.spec.ts +++ b/packages/ibkr/tests/reader-batching.spec.ts @@ -35,6 +35,19 @@ class FakeConnection extends EventEmitter { } } +/** `count` framed messages whose payload is their own big-endian index. */ +function numberedFrames(count: number): Buffer[] { + const frames: Buffer[] = [] + for (let i = 0; i < count; i++) { + const payload = Buffer.alloc(4) + payload.writeUInt32BE(i) + const header = Buffer.alloc(4) + header.writeUInt32BE(payload.length) + frames.push(Buffer.concat([header, payload])) + } + return frames +} + function currentTimeFrame(value: number): Buffer { return makeMsg(IN.CURRENT_TIME, true, makeField(1) + makeField(value)) } @@ -49,14 +62,7 @@ describe('EReader dispatch batching', () => { reader.start() const burst = MAX_MESSAGES_PER_TURN * 2 + 5 - const frames: Buffer[] = [] - for (let i = 0; i < burst; i++) { - const payload = Buffer.alloc(4) - payload.writeUInt32BE(i) - const header = Buffer.alloc(4) - header.writeUInt32BE(payload.length) - frames.push(Buffer.concat([header, payload])) - } + const frames = numberedFrames(burst) const timerFired = new Promise((resolve) => { setTimeout(() => { @@ -81,6 +87,38 @@ describe('EReader dispatch batching', () => { // A timer got a turn before the last message was dispatched. expect(trace.indexOf('timer')).toBeLessThan(trace.length - 1) }) + + it('drains a many-turn burst in order', async () => { + const connection = new FakeConnection() + const seen: number[] = [] + const reader = new EReader(connection as never, (msg) => { + seen.push(msg.readUInt32BE(0)) + }) + reader.start() + + const burst = MAX_MESSAGES_PER_TURN * 5 + 3 + connection.push(Buffer.concat(numberedFrames(burst))) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(seen).toEqual(Array.from({ length: burst }, (_, i) => i)) + }) + + it('delivers nothing further when stop lands mid-burst', async () => { + const connection = new FakeConnection() + const seen: number[] = [] + const reader = new EReader(connection as never, (msg) => { + seen.push(msg.readUInt32BE(0)) + }) + reader.start() + + connection.push(Buffer.concat(numberedFrames(MAX_MESSAGES_PER_TURN * 3))) + expect(seen).toHaveLength(MAX_MESSAGES_PER_TURN) + + reader.stop() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(seen).toEqual(Array.from({ length: MAX_MESSAGES_PER_TURN }, (_, i) => i)) + }) }) function connectedClient(wrapper: DefaultEWrapper): { @@ -166,14 +204,7 @@ describe('EReader teardown', () => { reader.start() const burst = MAX_MESSAGES_PER_TURN * 2 - const frames: Buffer[] = [] - for (let i = 0; i < burst; i++) { - const payload = Buffer.alloc(4) - payload.writeUInt32BE(i) - const header = Buffer.alloc(4) - header.writeUInt32BE(payload.length) - frames.push(Buffer.concat([header, payload])) - } + const frames = numberedFrames(burst) connection.push(Buffer.concat(frames)) expect(seen).toHaveLength(MAX_MESSAGES_PER_TURN) From dcf19c2116251ad00c06d7e7791e0ae088c21126 Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:52:24 -0700 Subject: [PATCH 7/9] fix(uta): build the IBKR historical request when the queued task runs buildHistoricalRequest ran before enqueueHistorical, so a start-only window kept a durationStr computed for the enqueue-time "now" and under-fetched by however long the pacing queue held the task. Derive the request inside the queued task and re-check liveness there so a queued request cannot transmit on a socket that died while it waited. --- .../trading/brokers/ibkr/IbkrBroker.spec.ts | 71 +++++++++++++++++++ .../domain/trading/brokers/ibkr/IbkrBroker.ts | 7 +- 2 files changed, 76 insertions(+), 2 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 6bc5144fe6..558387a41c 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest' import Decimal from 'decimal.js' import { Contract, Order, UNSET_DECIMAL } from '@traderalice/ibkr' import { IbkrBroker } from './IbkrBroker.js' +import { buildHistoricalRequest } from './ibkr-historical.js' +import type { BarParams } from '../types.js' import contractCorpus from './__fixtures__/contract-resolution.v1.json' /** @@ -42,9 +44,12 @@ function usdChfContract(): Contract { function brokerWithContractIo(resolvedContract = usdChfContract()): { broker: IbkrBroker bridge: { + connectionDead: boolean + allocReqId: ReturnType requestCollector: ReturnType requestSnapshot: ReturnType requestCurrentTime: ReturnType + requestHistoricalBars: ReturnType getNextOrderId: ReturnType lastInboundAt: number requestOrder: ReturnType @@ -54,6 +59,8 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { isConnected: ReturnType reqContractDetails: ReturnType reqMktData: ReturnType + reqHistoricalData: ReturnType + cancelHistoricalData: ReturnType placeOrder: ReturnType cancelOrder: ReturnType } @@ -66,6 +73,7 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { requestCollector: vi.fn(async () => [{ contract: Object.assign(new Contract(), resolvedContract) }]), requestSnapshot: vi.fn(async () => ({ last: 0.8, bid: 0.79, ask: 0.81, volume: 1 })), requestCurrentTime: vi.fn(async () => 1_784_289_600), + requestHistoricalBars: vi.fn(async () => []), getNextOrderId: vi.fn(() => nextOrderId++), // Mirrors the bridge: only a cancel-confirming status satisfies `accepts`. requestOrder: vi.fn(async (_orderId: number, _timeoutMs?: number, accepts?: (status: string) => boolean) => ({ @@ -78,6 +86,8 @@ function brokerWithContractIo(resolvedContract = usdChfContract()): { isConnected: vi.fn(() => true), reqContractDetails: vi.fn(), reqMktData: vi.fn(), + reqHistoricalData: vi.fn(), + cancelHistoricalData: vi.fn(), placeOrder: vi.fn(), cancelOrder: vi.fn(), } @@ -964,3 +974,64 @@ describe('IbkrBroker — OCA revision and echo verification on modifyOrder', () expect(broker.getCapabilities().supportedOrderTypes).toContain('TRAIL LIMIT') }) }) + +describe('IbkrBroker — queued historical requests', () => { + const barParams: BarParams = { interval: '1m', start: new Date('2026-09-03T11:00:00.000Z') } + + /** Resolves once the broker has dispatched everything it can right now. */ + async function settle(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve() + } + + it('derives the window when the queued request runs, not when it is enqueued', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-09-03T12:00:00.000Z')) + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + let releaseFirst: (bars: never[]) => void = () => {} + bridge.requestHistoricalBars.mockImplementationOnce( + () => new Promise((resolve) => { releaseFirst = resolve }), + ) + + const first = broker.getHistorical(recordedContract('aapl-stock'), barParams) + await settle() + const second = broker.getHistorical(recordedContract('aapl-stock'), barParams) + await settle() + expect(client.reqHistoricalData).toHaveBeenCalledOnce() + + const dispatchedAt = new Date('2026-09-03T12:30:00.000Z') + vi.setSystemTime(dispatchedAt) + releaseFirst([]) + await expect(first).resolves.toEqual([]) + await expect(second).resolves.toEqual([]) + + const expected = buildHistoricalRequest(barParams, dispatchedAt, 'STK') + const queuedCall = client.reqHistoricalData.mock.calls[1] + expect(queuedCall[2]).toBe(expected.endDateTime) + expect(queuedCall[3]).toBe(expected.durationStr) + expect(queuedCall[3]).not.toBe(client.reqHistoricalData.mock.calls[0][3]) + } finally { + vi.useRealTimers() + } + }) + + it('refuses a queued request whose socket died while it waited', async () => { + const { broker, bridge, client } = brokerWithContractIo(recordedContract('aapl-stock')) + let releaseFirst: (bars: never[]) => void = () => {} + bridge.requestHistoricalBars.mockImplementationOnce( + () => new Promise((resolve) => { releaseFirst = resolve }), + ) + + const first = broker.getHistorical(recordedContract('aapl-stock'), barParams) + await settle() + const second = broker.getHistorical(recordedContract('aapl-stock'), barParams) + await settle() + + bridge.connectionDead = true + releaseFirst([]) + + await expect(first).resolves.toEqual([]) + await expect(second).rejects.toThrow(/connection lost/i) + expect(client.reqHistoricalData).toHaveBeenCalledOnce() + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 09eb68c53d..7bf7bd2003 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -1140,10 +1140,13 @@ export class IbkrBroker implements IBroker { async getHistorical(contract: Contract, params: BarParams): Promise { this._ensureAlive() const routedContract = await this.resolveRoutableContract(contract) - // secType picks the default price stream: spot FX (`CASH`) has no TRADES. - const request = buildHistoricalRequest(params, new Date(), routedContract.secType) return this.enqueueHistorical(async () => { + // Queue time can be long, so the window is derived and the socket + // re-checked at dispatch rather than at enqueue. + this._ensureAlive() + // secType picks the default price stream: spot FX (`CASH`) has no TRADES. + const request = buildHistoricalRequest(params, new Date(), routedContract.secType) const reqId = this.bridge.allocReqId() const promise = this.bridge.requestHistoricalBars(reqId) this.client.reqHistoricalData( From 9f1de0924405c77cc1fddfdd9bc4475b03a281aa Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:53:00 -0700 Subject: [PATCH 8/9] fix(uta): refuse a non-positive parentId at the staging boundary IBKR reads parentId 0 as "no parent", so 0 and negative ids were accepted and the bracket linkage vanished silently. Reject them with the same error shape as a malformed id. --- .../src/domain/trading/UnifiedTradingAccount.spec.ts | 12 +++++++++--- services/uta/src/domain/trading/oca.ts | 7 ++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts index ddabd87635..86fe26a7ee 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -760,16 +760,22 @@ describe('UTA — stageModifyOrder', () => { it('refuses a malformed parentId on both staging paths instead of coercing it to 0', () => { expect(() => uta.stageModifyOrder({ orderId: 'ord-1', parentId: 'abc' })) - .toThrow(/parentId must be a numeric order id/) + .toThrow(/parentId must be a positive numeric order id/) expect(() => uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1', parentId: 'abc', - })).toThrow(/parentId must be a numeric order id/) + })).toThrow(/parentId must be a positive numeric order id/) }) it('refuses a partly numeric parentId and accepts a whole one', () => { for (const bad of ['12abc', '1.9']) { expect(() => uta.stageModifyOrder({ orderId: 'ord-1', parentId: bad })) - .toThrow(/parentId must be a numeric order id/) + .toThrow(/parentId must be a positive numeric order id/) + } + // IBKR reads parentId 0 as "no parent", so a non-positive id would drop + // the linkage the caller asked for. + for (const bad of ['0', 0, '-3', -3]) { + expect(() => uta.stageModifyOrder({ orderId: 'ord-1', parentId: bad })) + .toThrow(/parentId must be a positive numeric order id/) } for (const good of ['42', ' 42 ']) { uta.stageModifyOrder({ orderId: 'ord-1', parentId: good }) diff --git a/services/uta/src/domain/trading/oca.ts b/services/uta/src/domain/trading/oca.ts index 24ec716fc9..4f68c8ab59 100644 --- a/services/uta/src/domain/trading/oca.ts +++ b/services/uta/src/domain/trading/oca.ts @@ -31,14 +31,15 @@ export function assertOcaType(value: number): OcaType { /** * Parses a caller-supplied `parentId` at the staging boundary. Coercing a * malformed id to `0` would rest the order standalone while the caller believes - * it is bracket-attached. + * it is bracket-attached, and `0` is exactly how IBKR spells "no parent", so a + * non-positive id is refused rather than passed through. */ export function parseOrderLinkId(value: string | number, op: string): number { // `parseInt` stops at the first non-digit, so '12abc' and '1.9' would pass // as ids the caller never meant. const parsed = typeof value === 'number' ? value : (/^-?\d+$/.test(value.trim()) ? Number(value.trim()) : NaN) - if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { - throw new Error(`${op}: parentId must be a numeric order id; got ${JSON.stringify(value)}.`) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${op}: parentId must be a positive numeric order id; got ${JSON.stringify(value)}.`) } return parsed } From 4e812c2ecc3b0c381430495136a212079e2a7b9b Mon Sep 17 00:00:00 2001 From: eutialia Date: Fri, 4 Sep 2026 20:53:32 -0700 Subject: [PATCH 9/9] fix(ui): stamp demo bar session only for the UTA source The demo /api/bars handler echoed meta.session/sessionForced for any query carrying a session, including vendor sources the real route never sessions. --- ui/src/demo/handlers/market.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/demo/handlers/market.ts b/ui/src/demo/handlers/market.ts index 268975bee1..8df467d660 100644 --- a/ui/src/demo/handlers/market.ts +++ b/ui/src/demo/handlers/market.ts @@ -132,12 +132,17 @@ export const marketHandlers = [ }) })() const sourceId = barId ? barId.split('|')[0] : 'yfinance' + const source = sourceId === 'alpaca-paper' ? 'uta' : 'vendor' const session = url.searchParams.get('session') const meta: BarMeta = { symbol: selected.symbol, from: results[0]?.date ?? '', to: results[results.length - 1]?.date ?? '', bars: results.length, - source: sourceId === 'alpaca-paper' ? 'uta' : 'vendor', sourceId, barId: barId ?? `${sourceId}|${selected.symbol}`, + source, sourceId, barId: barId ?? `${sourceId}|${selected.symbol}`, provider: sourceId, barCapability: sourceId === 'alpaca-paper' ? 'iex' : 'delayed', - ...(session === 'regular' || session === 'extended' ? { session, sessionForced: false } : {}), + // Only the UTA route resolves a session; vendor bars carry none however + // the query was spelled. + ...(source === 'uta' && (session === 'regular' || session === 'extended') + ? { session, sessionForced: false } + : {}), } return HttpResponse.json({ results, meta }) }),