diff --git a/Dockerfile b/Dockerfile index bc05ce6e..dc2c5bce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 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 5052aa69..83d4a487 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.spec.ts @@ -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 }).conIdContracts = new Map() + const captured: { contract?: Contract } = {} + let calls = 0 + ;(b as unknown as { getContractDetails: (q: Contract) => Promise }).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() diff --git a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 22313914..d27ead68 100644 --- a/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/services/uta/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -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 { + 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 { // Attached TP/SL: not implemented yet (native path = parent + child // orders with parentId + transmit chain — see ANG-103 batch). Refuse @@ -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) @@ -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() async getQuote(contract: Contract): Promise { - // 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)