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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ ENV OPENALICE_APP_HOME=/app \
OPENALICE_UTA_PORT=47333 \
OPENALICE_BIND_HOST=0.0.0.0

VOLUME ["/data"]
#VOLUME ["/data"]
EXPOSE 47331

# tini handles signal forwarding + zombie reaping; Guardian then spawns
Expand Down
85 changes: 85 additions & 0 deletions services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,91 @@ describe('IbkrBroker — getAccount mixed-currency math (ANG-101 / issues #295 #
})
})

describe('IbkrBroker — placeOrder resolves the real contract before routing (forex/CASH)', () => {
// The bug: placeOrder defaulted a bare { conId, symbol } to STK SMART/USD,
// so forex (e.g. USDCHF, conId 12087820) reached IBKR as the wrong contract
// and was rejected with "error 200: No security definition". The fix reuses
// the same resolver the quote/details path uses (resolveFullContract), which
// round-trips the conId to its real CASH/IDEALPRO identity.

/** What reqContractDetails returns for the forex conId. */
function usdChfContract(): Contract {
const c = new Contract()
c.conId = 12087820
c.symbol = 'USD'
c.secType = 'CASH'
c.currency = 'CHF'
c.exchange = 'IDEALPRO'
c.localSymbol = 'USD.CHF'
return c
}

/** Wire up a bare instance: alive bridge, captured client.placeOrder, and a
* getContractDetails stub that records whether it was called. */
function orderBroker(detailsContract: Contract | null): {
broker: IbkrBroker
captured: { contract?: Contract }
detailsCalls: () => number
} {
const b = bareBroker()
;(b as unknown as { conIdContracts: Map<number, Contract> }).conIdContracts = new Map()
const captured: { contract?: Contract } = {}
let calls = 0
;(b as unknown as { getContractDetails: (q: Contract) => Promise<unknown> }).getContractDetails =
async () => { calls++; return detailsContract ? { contract: detailsContract } : null }
;(b as unknown as { bridge: unknown }).bridge = {
connectionDead: false,
getNextOrderId: () => 1,
requestOrder: () => Promise.resolve({ orderState: { status: 'Submitted' } }),
}
;(b as unknown as { client: unknown }).client = {
placeOrder: (_id: number, contract: Contract) => { captured.contract = contract },
}
return { broker: b, captured, detailsCalls: () => calls }
}

it('Case A: a bare { conId, symbol: "USDCHF" } order is sent as CASH/IDEALPRO, matching getContractDetails', async () => {
const resolved = usdChfContract()
const { broker, captured, detailsCalls } = orderBroker(resolved)

const bare = new Contract()
bare.conId = 12087820
bare.symbol = 'USDCHF' // the wrong/loose symbol staging hands us
const order = new Order()
order.action = 'BUY'
order.orderType = 'LMT'
order.totalQuantity = new Decimal(1000)
order.lmtPrice = new Decimal('0.79')

const r = await broker.placeOrder(bare, order)

expect(r.success).toBe(true)
expect(detailsCalls()).toBe(1) // the conId was actually resolved, not defaulted
// The contract handed to TWS is the real forex contract...
expect(captured.contract).toEqual(usdChfContract())
// ...i.e. CASH / USD / CHF / IDEALPRO / USD.CHF, never STK SMART/USD.
expect(captured.contract?.secType).toBe('CASH')
expect(captured.contract?.exchange).toBe('IDEALPRO')
expect(captured.contract?.currency).toBe('CHF')
expect(captured.contract?.localSymbol).toBe('USD.CHF')
})

it('Case B: a fully-typed STK order passes through unchanged, with no getContractDetails round-trip', async () => {
const { broker, captured, detailsCalls } = orderBroker(null)
const { contract, order } = stkOrder()

const r = await broker.placeOrder(contract, order)

expect(r.success).toBe(true)
expect(detailsCalls()).toBe(0) // no resolution needed for an already-typed contract
expect(captured.contract).toBe(contract) // same object, untouched
expect(captured.contract?.symbol).toBe('AAPL')
expect(captured.contract?.secType).toBe('STK')
expect(captured.contract?.exchange).toBe('SMART')
expect(captured.contract?.currency).toBe('USD')
})
})

describe('IbkrBroker — dead-connection gate (issue #294)', () => {
it('cache-backed reads and order paths refuse loudly when the socket is known-dead', async () => {
const b = bareBroker()
Expand Down
78 changes: 47 additions & 31 deletions services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,39 @@ export class IbkrBroker implements IBroker {

// ==================== Trading operations ====================

/**
* Resolve a possibly-bare contract to its real, routable identity before it
* reaches the venue. Staging / AI tools hand us a reference like
* `{ conId, symbol }` with no secType/exchange/currency — for forex that
* means CASH on IDEALPRO, NOT the STK SMART/USD a naive default picks (which
* IBKR rejects with error 200). A conId without a secType is the unresolved
* case: round-trip it through reqContractDetails once (cached), exactly like
* the quote path. Already-typed contracts only get routing defaults backfilled.
*
* Shared by getQuote and placeOrder so the two paths can never diverge.
*/
private async resolveFullContract(contract: Contract): Promise<Contract> {
if (contract.conId && !contract.secType) {
let full = this.conIdContracts.get(contract.conId)
if (!full) {
// Query by conId alone — a stray symbol on the input ('USDCHF' rather
// than the real 'USD'/'USD.CHF') would otherwise narrow TWS to nothing.
const details = await this.getContractDetails(Object.assign(new Contract(), { conId: contract.conId }))
if (!details?.contract) {
throw new BrokerError('EXCHANGE', `conId ${contract.conId} did not resolve to a contract`)
}
full = details.contract
this.conIdContracts.set(contract.conId, full)
}
return full
}
// No conId, or already typed: just backfill routing. CASH (forex) lives on
// IDEALPRO; everything else routes SMART. Currency defaults to USD.
if (!contract.exchange) contract.exchange = contract.secType === 'CASH' ? 'IDEALPRO' : 'SMART'
if (!contract.currency) contract.currency = 'USD'
return contract
}

async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise<PlaceOrderResult> {
// Attached TP/SL: not implemented yet (native path = parent + child
// orders with parentId + transmit chain — see ANG-103 batch). Refuse
Expand All @@ -357,16 +390,14 @@ export class IbkrBroker implements IBroker {
error: 'IBKR attached TP/SL (bracket) is not implemented yet — refusing to place a naked entry. Place the entry first, then a standalone STP/LMT protective order.',
}
}
// TWS requires exchange and currency on the contract. Upstream layers
// (staging, AI tools) typically only populate symbol + secType.
// Default to SMART routing. Currency defaults to USD — non-USD markets
// (LSE/GBP, TSE/JPY) and forex (CASH secType) will need the caller
// to specify currency explicitly.
if (!contract.exchange) contract.exchange = 'SMART'
if (!contract.currency) contract.currency = 'USD'

try {
this._ensureAlive()
// Resolve the instrument's real contract before routing. A bare
// { conId, symbol } has no secType/exchange/currency; without this a
// forex order defaults to STK SMART/USD and IBKR rejects it (error 200).
// Inside the try so a resolution failure returns a clean error result —
// we never hand a naked, mis-routed contract to client.placeOrder.
contract = await this.resolveFullContract(contract)
const orderId = this.bridge.getNextOrderId()
const promise = this.bridge.requestOrder(orderId)
this.client.placeOrder(orderId, contract, order)
Expand Down Expand Up @@ -614,32 +645,17 @@ export class IbkrBroker implements IBroker {
* Each call briefly occupies one TWS market data line (limit ~100),
* auto-released after tickSnapshotEnd.
*/
/** conId → resolved full contract, so by-conId quotes pay reqContractDetails once. */
/** conId → resolved full contract, so by-conId quotes and orders pay
* reqContractDetails once. Shared via resolveFullContract. */
private readonly conIdContracts = new Map<number, Contract>()

async getQuote(contract: Contract): Promise<Quote> {
// Enrichment must run BEFORE routing defaults: a premature SMART poisons
// the conId details lookup for anything not on SMART (EUR.USD@IDEALPRO).
// The enriched contract carries its real exchange/currency.

// TWS rejects reqMktData on a bare conId (error 321: symbol/localSymbol/
// secId required) even though the wire carries conId — resolution by
// conId is only honoured via reqContractDetails. Enrich once and cache.
if (contract.conId && !contract.symbol && !contract.localSymbol) {
let full = this.conIdContracts.get(contract.conId)
if (!full) {
const details = await this.getContractDetails(contract)
if (!details?.contract) {
throw new BrokerError('EXCHANGE', `conId ${contract.conId} did not resolve to a contract`)
}
full = details.contract
this.conIdContracts.set(contract.conId, full)
}
contract = full
}

if (!contract.exchange) contract.exchange = 'SMART'
if (!contract.currency) contract.currency = 'USD'
// Resolve to the real contract BEFORE routing — a premature SMART poisons
// the conId details lookup for anything not on SMART (EUR.USD@IDEALPRO),
// and TWS rejects reqMktData on a bare conId (error 321: symbol/localSymbol/
// secId required). resolveFullContract is the same path placeOrder uses, so
// a quote and the order it informs can never resolve to different contracts.
contract = await this.resolveFullContract(contract)

const reqId = this.bridge.allocReqId()
const promise = this.bridge.requestSnapshot(reqId)
Expand Down