diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts index 9ed4c180..70e909f9 100644 --- a/src/connectors/web/__tests__/chat-streaming.spec.ts +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -236,7 +236,7 @@ describe('Web UI chat streaming', () => { const provider = new FakeProvider([ toolUseEvent('t1', 'getAccount', {}), toolResultEvent('t1', '{"cash": 100000}'), - toolUseEvent('t2', 'getQuote', { aliceId: 'alpaca-AAPL' }), + toolUseEvent('t2', 'getQuote', { aliceId: 'mock-paper|AAPL' }), toolResultEvent('t2', '{"last": 255.71}'), textEvent('Account has $100k, AAPL at $255.71'), doneEvent('Account has $100k, AAPL at $255.71'), diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 0aac3fbf..d2ec209d 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -57,7 +57,7 @@ describe('UTA — operation dispatch', () => { it('passes aliceId and extra contract fields', async () => { const spy = vi.spyOn(broker, 'placeOrder') const contract = makeContract({ - aliceId: 'alpaca-AAPL', + aliceId: 'mock-paper|AAPL', symbol: 'AAPL', secType: 'STK', currency: 'USD', @@ -74,7 +74,7 @@ describe('UTA — operation dispatch', () => { await uta.push() const [passedContract, passedOrder] = spy.mock.calls[0] - expect(passedContract.aliceId).toBe('alpaca-AAPL') + expect(passedContract.aliceId).toBe('mock-paper|AAPL') expect(passedContract.secType).toBe('STK') expect(passedContract.currency).toBe('USD') expect(passedContract.exchange).toBe('NASDAQ') @@ -243,13 +243,13 @@ describe('UTA — stagePlaceOrder', () => { }) it('maps buy side to BUY action', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.action).toBe('BUY') }) it('maps sell side to SELL action', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'sell', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.action).toBe('SELL') }) @@ -264,54 +264,74 @@ describe('UTA — stagePlaceOrder', () => { ] for (const [input, expected] of cases) { const { uta: u } = createUTA() - u.stagePlaceOrder({ aliceId: 'a-X', side: 'buy', type: input, qty: 1 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|X', side: 'buy', type: input, qty: 1 }) const { order } = getStagedPlaceOrder(u) expect(order.orderType).toBe(expected) } }) it('maps qty to totalQuantity as Decimal', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 42 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 42 }) const { order } = getStagedPlaceOrder(uta) expect(order.totalQuantity).toBeInstanceOf(Decimal) expect(order.totalQuantity.toNumber()).toBe(42) }) it('maps notional to cashQty', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', notional: 5000 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', notional: 5000 }) const { order } = getStagedPlaceOrder(uta) expect(order.cashQty).toBe(5000) }) it('maps price to lmtPrice and stopPrice to auxPrice', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'stop_limit', qty: 10, price: 150, stopPrice: 145 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'stop_limit', qty: 10, price: 150, stopPrice: 145 }) const { order } = getStagedPlaceOrder(uta) expect(order.lmtPrice).toBe(150) expect(order.auxPrice).toBe(145) }) + it('maps trailingAmount to trailStopPrice (not auxPrice)', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, trailingAmount: 5 }) + const { order } = getStagedPlaceOrder(uta) + expect(order.trailStopPrice).toBe(5) + expect(order.orderType).toBe('TRAIL') + }) + + it('trailingAmount and stopPrice use separate fields', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, stopPrice: 145, trailingAmount: 5 }) + const { order } = getStagedPlaceOrder(uta) + expect(order.auxPrice).toBe(145) + expect(order.trailStopPrice).toBe(5) + }) + + it('maps trailingPercent to trailingPercent', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, trailingPercent: 2.5 }) + const { order } = getStagedPlaceOrder(uta) + expect(order.trailingPercent).toBe(2.5) + }) + it('defaults timeInForce to DAY', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.tif).toBe('DAY') }) it('allows overriding timeInForce', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, timeInForce: 'gtc' }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, timeInForce: 'gtc' }) const { order } = getStagedPlaceOrder(uta) expect(order.tif).toBe('GTC') }) it('maps extendedHours to outsideRth', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, extendedHours: true }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, extendedHours: true }) const { order } = getStagedPlaceOrder(uta) expect(order.outsideRth).toBe(true) }) it('sets aliceId and symbol on contract', () => { - uta.stagePlaceOrder({ aliceId: 'alpaca-AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) const { contract } = getStagedPlaceOrder(uta) - expect(contract.aliceId).toBe('alpaca-AAPL') + expect(contract.aliceId).toBe('mock-paper|AAPL') expect(contract.symbol).toBe('AAPL') }) }) @@ -360,17 +380,17 @@ describe('UTA — stageClosePosition', () => { }) it('stages with Decimal quantity when qty provided', () => { - uta.stageClosePosition({ aliceId: 'a-AAPL', qty: 5 }) + uta.stageClosePosition({ aliceId: 'mock-paper|AAPL', qty: 5 }) const staged = uta.status().staged const op = staged[0] as Extract expect(op.action).toBe('closePosition') - expect(op.contract.aliceId).toBe('a-AAPL') + expect(op.contract.aliceId).toBe('mock-paper|AAPL') expect(op.quantity).toBeInstanceOf(Decimal) expect(op.quantity!.toNumber()).toBe(5) }) it('stages with undefined quantity for full close', () => { - uta.stageClosePosition({ aliceId: 'a-AAPL' }) + uta.stageClosePosition({ aliceId: 'mock-paper|AAPL' }) const staged = uta.status().staged const op = staged[0] as Extract expect(op.quantity).toBeUndefined() @@ -405,15 +425,15 @@ describe('UTA — git flow', () => { }) it('push throws when not committed', async () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) await expect(uta.push()).rejects.toThrow('please commit first') }) it('executes multiple operations in a single push', async () => { const { uta: u, broker: b } = createUTA() const spy = vi.spyOn(b, 'placeOrder') - u.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) - u.stagePlaceOrder({ aliceId: 'a-MSFT', symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|MSFT', symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 }) u.commit('buy both') await u.push() @@ -421,7 +441,7 @@ describe('UTA — git flow', () => { }) it('clears staging area after push', async () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy') await uta.push() @@ -483,7 +503,7 @@ describe('UTA — guards', () => { }) const spy = vi.spyOn(broker, 'placeOrder') - uta.stagePlaceOrder({ aliceId: 'a-TSLA', symbol: 'TSLA', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|TSLA', symbol: 'TSLA', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy TSLA (should be blocked)') const result = await uta.push() @@ -498,7 +518,7 @@ describe('UTA — guards', () => { }) const spy = vi.spyOn(broker, 'placeOrder') - uta.stagePlaceOrder({ aliceId: 'a-AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL (allowed)') await uta.push() @@ -512,7 +532,7 @@ describe('UTA — constructor', () => { it('restores from savedState', async () => { // Create a UTA, push a commit, export state const { uta: original } = createUTA() - original.stagePlaceOrder({ aliceId: 'a-AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + original.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) original.commit('initial buy') await original.push() @@ -640,7 +660,7 @@ describe('UTA — health tracking', () => { await expect(uta.getAccount()).rejects.toThrow() } - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL') await expect(uta.push()).rejects.toThrow(/offline/) await uta.close() diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 22aaad4b..b585d91a 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -358,9 +358,10 @@ export class UnifiedTradingAccount { stagePlaceOrder(params: StagePlaceOrderParams): AddResult { // Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.) const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) - const contract = parsed - ? this.broker.resolveNativeKey(parsed.nativeKey) - : new Contract() + if (!parsed) { + throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`) + } + const contract = this.broker.resolveNativeKey(parsed.nativeKey) contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol @@ -373,7 +374,7 @@ export class UnifiedTradingAccount { if (params.notional != null) order.cashQty = params.notional if (params.price != null) order.lmtPrice = params.price if (params.stopPrice != null) order.auxPrice = params.stopPrice - if (params.trailingAmount != null) order.auxPrice = params.trailingAmount + if (params.trailingAmount != null) order.trailStopPrice = params.trailingAmount if (params.trailingPercent != null) order.trailingPercent = params.trailingPercent if (params.goodTillDate != null) order.goodTillDate = params.goodTillDate if (params.extendedHours) order.outsideRth = true @@ -388,7 +389,7 @@ export class UnifiedTradingAccount { if (params.qty != null) changes.totalQuantity = new Decimal(String(params.qty)) if (params.price != null) changes.lmtPrice = params.price if (params.stopPrice != null) changes.auxPrice = params.stopPrice - if (params.trailingAmount != null) changes.auxPrice = params.trailingAmount + if (params.trailingAmount != null) changes.trailStopPrice = params.trailingAmount if (params.trailingPercent != null) changes.trailingPercent = params.trailingPercent if (params.type != null) changes.orderType = toIbkrOrderType(params.type) if (params.timeInForce != null) changes.tif = toIbkrTif(params.timeInForce) @@ -399,9 +400,10 @@ export class UnifiedTradingAccount { stageClosePosition(params: StageClosePositionParams): AddResult { const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) - const contract = parsed - ? this.broker.resolveNativeKey(parsed.nativeKey) - : new Contract() + if (!parsed) { + throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`) + } + const contract = this.broker.resolveNativeKey(parsed.nativeKey) contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol diff --git a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts index f9b5b23d..16c7ab75 100644 --- a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts @@ -118,7 +118,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('fetches AAPL quote with valid prices', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const quote = await broker!.getQuote(contract) @@ -131,7 +131,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('places market buy 1 AAPL → success with UUID orderId', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' @@ -151,7 +151,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('queries order by ID after place', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' @@ -187,7 +187,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('closes AAPL position', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const result = await broker!.closePosition(contract) @@ -197,7 +197,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('getOrders with known IDs', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' diff --git a/src/domain/trading/__test__/uta-health.spec.ts b/src/domain/trading/__test__/uta-health.spec.ts index 77a891e2..4dbeaba7 100644 --- a/src/domain/trading/__test__/uta-health.spec.ts +++ b/src/domain/trading/__test__/uta-health.spec.ts @@ -215,7 +215,7 @@ describe('UTA health — offline behavior', () => { await expect(uta.getAccount()).rejects.toThrow() } - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL') await expect(uta.push()).rejects.toThrow(/offline/) await uta.close() @@ -231,7 +231,7 @@ describe('UTA health — offline behavior', () => { } // Staging is a local operation — should work even when offline - const result = uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + const result = uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) expect(result.staged).toBe(true) const commit = uta.commit('buy while offline') diff --git a/src/domain/trading/account-manager.spec.ts b/src/domain/trading/account-manager.spec.ts index 49fe8059..7f25a10f 100644 --- a/src/domain/trading/account-manager.spec.ts +++ b/src/domain/trading/account-manager.spec.ts @@ -127,12 +127,12 @@ describe('AccountManager', () => { it('searches all accounts by default', async () => { const a1 = new MockBroker({ id: 'a1' }) const desc1 = new ContractDescription() - desc1.contract = makeContract({ aliceId: 'a1-AAPL' }) + desc1.contract = makeContract({ aliceId: 'a1|AAPL' }) vi.spyOn(a1, 'searchContracts').mockResolvedValue([desc1]) const a2 = new MockBroker({ id: 'a2' }) const desc2 = new ContractDescription() - desc2.contract = makeContract({ aliceId: 'a2-AAPL' }) + desc2.contract = makeContract({ aliceId: 'a2|AAPL' }) vi.spyOn(a2, 'searchContracts').mockResolvedValue([desc2]) manager.add(makeUta(a1)) @@ -145,12 +145,12 @@ describe('AccountManager', () => { it('scopes search to specific accountId', async () => { const a1 = new MockBroker({ id: 'a1' }) const desc1 = new ContractDescription() - desc1.contract = makeContract({ aliceId: 'a1-AAPL' }) + desc1.contract = makeContract({ aliceId: 'a1|AAPL' }) vi.spyOn(a1, 'searchContracts').mockResolvedValue([desc1]) const a2 = new MockBroker({ id: 'a2' }) const desc2 = new ContractDescription() - desc2.contract = makeContract({ aliceId: 'a2-AAPL' }) + desc2.contract = makeContract({ aliceId: 'a2|AAPL' }) vi.spyOn(a2, 'searchContracts').mockResolvedValue([desc2]) manager.add(makeUta(a1)) diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts index f08e1307..266b5351 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts @@ -104,7 +104,7 @@ describe('AlpacaBroker — placeOrder()', () => { }), } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' contract.exchange = 'NASDAQ' @@ -148,7 +148,7 @@ describe('AlpacaBroker — precision', () => { createOrder: vi.fn().mockResolvedValue({ id: 'ord-p', status: 'new' }), } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' contract.exchange = 'NASDAQ' @@ -199,7 +199,7 @@ describe('AlpacaBroker — getContractDetails()', () => { it('returns ContractDetails for a valid symbol', async () => { const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) const query = new Contract() - query.aliceId = 'alpaca-AAPL' + query.aliceId = 'alpaca-paper|AAPL' query.symbol = 'AAPL' const details = await acc.getContractDetails(query) @@ -312,7 +312,7 @@ describe('AlpacaBroker — closePosition()', () => { } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const result = await acc.closePosition(contract) @@ -341,7 +341,7 @@ describe('AlpacaBroker — closePosition()', () => { } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const result = await acc.closePosition(contract, new Decimal(3)) @@ -532,7 +532,7 @@ describe('AlpacaBroker — getQuote()', () => { } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const quote = await acc.getQuote(contract) diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index ed37f74f..79872840 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -37,7 +37,7 @@ import { import '../../contract-ext.js' import { RequestBridge } from './request-bridge.js' import { resolveSymbol } from './ibkr-contracts.js' -import type { IbkrBrokerConfig, AccountDownloadResult } from './ibkr-types.js' +import type { IbkrBrokerConfig } from './ibkr-types.js' export class IbkrBroker implements IBroker { // ---- Self-registration ---- @@ -110,9 +110,10 @@ export class IbkrBroker implements IBroker { throw new BrokerError('CONFIG', 'No account detected from TWS/Gateway. Set accountId in config for multi-account setups.') } - // Verify connection by fetching account data + // Start persistent account subscription and wait for first download try { - await this.getAccount() + this.bridge.startAccountSubscription(this.accountId) + await this.bridge.waitForAccountReady() console.log(`IbkrBroker[${this.id}]: connected (account=${this.accountId}, host=${host}:${port}, clientId=${clientId})`) } catch (err) { throw BrokerError.from(err, 'NETWORK') @@ -120,6 +121,7 @@ export class IbkrBroker implements IBroker { } async close(): Promise { + this.bridge.stopAccountSubscription() this.client.disconnect() } @@ -259,7 +261,8 @@ export class IbkrBroker implements IBroker { * will be stale even though Blue Ocean ATS prices may be moving. */ async getAccount(): Promise { - const download = await this.downloadAccount() + const download = this.bridge.getAccountCache() + if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') const totalCashValue = parseFloat(download.values.get('TotalCashValue') ?? '0') let totalMarketValue = 0 @@ -305,7 +308,8 @@ export class IbkrBroker implements IBroker { * snapshot mode and can see overnight session data. */ async getPositions(): Promise { - const download = await this.downloadAccount() + const download = this.bridge.getAccountCache() + if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') return download.positions } @@ -435,9 +439,4 @@ export class IbkrBroker implements IBroker { return c } - // ==================== Internal ==================== - - private downloadAccount(): Promise { - return this.bridge.requestAccountDownload(this.accountId!) - } } diff --git a/src/domain/trading/brokers/ibkr/request-bridge.ts b/src/domain/trading/brokers/ibkr/request-bridge.ts index b3b8c08f..c988b9b3 100644 --- a/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -6,7 +6,8 @@ * * A) reqId-based: symbolSamples, contractDetails, accountSummary, tickSnapshot * B) orderId-based: openOrder, orderStatus (for placeOrder/cancelOrder) - * C) Single-slot: accountDownload (updatePortfolio/updateAccountValue), openOrders batch + * C) Single-slot: openOrders batch, completedOrders batch + * D) Persistent subscription: account data (updatePortfolio/updateAccountValue) with cache */ import Decimal from 'decimal.js' @@ -35,7 +36,7 @@ import type { } from './ibkr-types.js' const DEFAULT_TIMEOUT_MS = 10_000 -const ACCOUNT_DOWNLOAD_TIMEOUT_MS = 20_000 +const ACCOUNT_READY_TIMEOUT_MS = 20_000 export class RequestBridge extends DefaultEWrapper { // ---- State ---- @@ -55,24 +56,6 @@ export class RequestBridge extends DefaultEWrapper { private orderPending = new Map>() // ---- Mode C: single-slot collectors ---- - private accountDownload: { - positions: Array<{ - contract: Contract - side: 'long' | 'short' - quantity: Decimal - avgCost: number - marketPrice: number - marketValue: number - unrealizedPnL: number - realizedPnL: number - }> - values: Map - resolve: (result: AccountDownloadResult) => void - reject: (err: Error) => void - timer: ReturnType - } | null = null - private accountDownloadLock: Promise | null = null - private openOrdersCollector: { orders: CollectedOpenOrder[] resolve: (orders: CollectedOpenOrder[]) => void @@ -87,6 +70,18 @@ export class RequestBridge extends DefaultEWrapper { timer: ReturnType } | null = null + // ---- Mode D: persistent account subscription cache ---- + private accountCache_: AccountDownloadResult | null = null + private accountCachePending_: { + positions: AccountDownloadResult['positions'] + values: Map + } | null = null + private accountReadyResolve_: (() => void) | null = null + private accountReadyReject_: ((err: Error) => void) | null = null + private accountReadyPromise_: Promise | null = null + private accountSubscribed_ = false + private accountCode_: string | null = null + // ---- Fill data cache (from orderStatus callbacks) ---- private fillData_ = new Map() @@ -185,37 +180,6 @@ export class RequestBridge extends DefaultEWrapper { // ---- Mode C: single-slot requests ---- - /** Request account download (positions + account values). Serial access via lock. */ - async requestAccountDownload(acctCode: string, timeoutMs = ACCOUNT_DOWNLOAD_TIMEOUT_MS): Promise { - // Queue behind any in-flight download - if (this.accountDownloadLock) { - await this.accountDownloadLock.catch(() => {}) - } - - const promise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.accountDownload = null - this.client_?.reqAccountUpdates(false, acctCode) - reject(new BrokerError('NETWORK', `Account download timed out after ${timeoutMs}ms`)) - }, timeoutMs) - - this.accountDownload = { - positions: [], - values: new Map(), - resolve, - reject, - timer, - } - }) - - this.accountDownloadLock = promise.finally(() => { - this.accountDownloadLock = null - }) - - this.client_!.reqAccountUpdates(true, acctCode) - return promise - } - /** Request all open orders (batch collector). */ requestOpenOrders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { @@ -260,6 +224,46 @@ export class RequestBridge extends DefaultEWrapper { }) } + // ---- Mode D: persistent account subscription ---- + + /** Subscribe to account updates. Call once after connect. */ + startAccountSubscription(acctCode: string): void { + if (this.accountSubscribed_) return + this.accountSubscribed_ = true + this.accountCode_ = acctCode + this.accountCachePending_ = { positions: [], values: new Map() } + this.accountReadyPromise_ = new Promise((resolve, reject) => { + this.accountReadyResolve_ = resolve + this.accountReadyReject_ = reject + }) + this.client_!.reqAccountUpdates(true, acctCode) + } + + /** Wait for first account download to complete. */ + async waitForAccountReady(timeoutMs = ACCOUNT_READY_TIMEOUT_MS): Promise { + if (this.accountCache_) return + if (!this.accountReadyPromise_) { + throw new BrokerError('NETWORK', 'Account subscription not started') + } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new BrokerError('NETWORK', `Initial account download timed out after ${timeoutMs}ms`)), timeoutMs), + ) + await Promise.race([this.accountReadyPromise_, timeout]) + } + + /** Read the cached account data. Returns null if not yet loaded. */ + getAccountCache(): AccountDownloadResult | null { + return this.accountCache_ + } + + /** Stop the account subscription. */ + stopAccountSubscription(): void { + if (!this.accountSubscribed_ || !this.accountCode_) return + this.accountSubscribed_ = false + this.client_?.reqAccountUpdates(false, this.accountCode_) + this.accountCode_ = null + } + // ==================== Internal helpers ==================== private resolveRequest(reqId: number, value: unknown): void { @@ -321,11 +325,15 @@ export class RequestBridge extends DefaultEWrapper { } this.orderPending.clear() - if (this.accountDownload) { - clearTimeout(this.accountDownload.timer) - this.accountDownload.reject(error) - this.accountDownload = null + // Reject account subscription ready promise if still pending + if (this.accountReadyReject_) { + this.accountReadyReject_(error) + this.accountReadyResolve_ = null + this.accountReadyReject_ = null } + this.accountSubscribed_ = false + this.accountCache_ = null + this.accountCachePending_ = null if (this.openOrdersCollector) { clearTimeout(this.openOrdersCollector.timer) @@ -433,7 +441,7 @@ export class RequestBridge extends DefaultEWrapper { this.resolveRequest(reqId, this.collectors.get(reqId) ?? new Map()) } - // ---- Account download (updatePortfolio + updateAccountValue) ---- + // ---- Account subscription callbacks (persistent cache) ---- override updatePortfolio( contract: Contract, @@ -445,10 +453,10 @@ export class RequestBridge extends DefaultEWrapper { realizedPNL: number, _accountName: string, ): void { - if (!this.accountDownload) return - if (position.isZero()) return // no position + if (!this.accountCachePending_) return + if (position.isZero()) return - this.accountDownload.positions.push({ + this.accountCachePending_.positions.push({ contract, side: position.greaterThan(0) ? 'long' : 'short', quantity: position.abs(), @@ -461,23 +469,27 @@ export class RequestBridge extends DefaultEWrapper { } override updateAccountValue(key: string, val: string, _currency: string, _accountName: string): void { - this.accountDownload?.values.set(key, val) + this.accountCachePending_?.values.set(key, val) } override accountDownloadEnd(_accountName: string): void { - if (!this.accountDownload) return - clearTimeout(this.accountDownload.timer) + if (!this.accountCachePending_) return - const result: AccountDownloadResult = { - values: this.accountDownload.values, - positions: this.accountDownload.positions, + // Swap pending buffer into cache (atomic replace) + this.accountCache_ = { + values: this.accountCachePending_.values, + positions: this.accountCachePending_.positions, } - this.accountDownload.resolve(result) - this.accountDownload = null + // Reset pending buffer for next batch + this.accountCachePending_ = { positions: [], values: new Map() } - // Unsubscribe - this.client_?.reqAccountUpdates(false, _accountName) + // Resolve the initial-load promise (first call only) + if (this.accountReadyResolve_) { + this.accountReadyResolve_() + this.accountReadyResolve_ = null + this.accountReadyReject_ = null + } } // ---- Market data snapshot ---- diff --git a/src/domain/trading/brokers/mock/MockBroker.spec.ts b/src/domain/trading/brokers/mock/MockBroker.spec.ts index f74dde08..b3904e7f 100644 --- a/src/domain/trading/brokers/mock/MockBroker.spec.ts +++ b/src/domain/trading/brokers/mock/MockBroker.spec.ts @@ -22,7 +22,7 @@ beforeEach(() => { describe('precision', () => { it('placeOrder quantity survives Decimal round-trip', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -36,7 +36,7 @@ describe('precision', () => { }) it('position quantity matches placed order exactly', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -49,7 +49,7 @@ describe('precision', () => { }) it('closePosition removes position completely', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -64,7 +64,7 @@ describe('precision', () => { }) it('partial close leaves correct remainder via Decimal subtraction', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const buyOrder = new Order() buyOrder.action = 'BUY' buyOrder.orderType = 'MKT' @@ -85,7 +85,7 @@ describe('precision', () => { describe('placeOrder', () => { it('market order returns submitted (fill confirmed via getOrder)', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -102,7 +102,7 @@ describe('placeOrder', () => { }) it('limit order stays submitted, no execution', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -118,7 +118,7 @@ describe('placeOrder', () => { it('creates position on buy', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -134,7 +134,7 @@ describe('placeOrder', () => { it('updates existing position on additional buy (avg cost recalc)', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order1 = new Order() order1.action = 'BUY' @@ -162,7 +162,7 @@ describe('placeOrder', () => { describe('closePosition', () => { it('closes full position', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -178,7 +178,7 @@ describe('closePosition', () => { it('partial close reduces quantity', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -193,7 +193,7 @@ describe('closePosition', () => { }) it('returns error when no position', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const result = await broker.closePosition(contract) expect(result.success).toBe(false) expect(result.error).toContain('No open position') @@ -204,7 +204,7 @@ describe('closePosition', () => { describe('cancelOrder', () => { it('cancels pending order', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -232,7 +232,7 @@ describe('cancelOrder', () => { describe('modifyOrder', () => { it('updates pending order qty/price', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -264,7 +264,7 @@ describe('modifyOrder', () => { describe('getOrder', () => { it('finds order by id', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -287,7 +287,7 @@ describe('getOrder', () => { describe('fillPendingOrder', () => { it('fills a pending limit order at specified price', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -319,7 +319,7 @@ describe('getAccount', () => { it('cash decreases after buy, equity includes unrealized PnL', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -342,7 +342,7 @@ describe('getAccount', () => { describe('call tracking', () => { it('records method calls with args', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) await broker.getQuote(contract) expect(broker.callCount('getQuote')).toBe(1) expect(broker.lastCall('getQuote')!.args[0]).toBe(contract) @@ -350,7 +350,7 @@ describe('call tracking', () => { it('tracks multiple calls', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -397,13 +397,13 @@ describe('accountInfo constructor option', () => { describe('factory helpers', () => { it('makeContract creates a contract with defaults', () => { const c = makeContract() - expect(c.aliceId).toBe('mock-AAPL') + expect(c.aliceId).toBe('mock-paper|AAPL') expect(c.symbol).toBe('AAPL') }) it('makeContract accepts overrides', () => { - const c = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH', secType: 'CRYPTO' }) - expect(c.aliceId).toBe('mock-ETH') + const c = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH', secType: 'CRYPTO' }) + expect(c.aliceId).toBe('mock-paper|ETH') expect(c.symbol).toBe('ETH') expect(c.secType).toBe('CRYPTO') }) diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index a77036e4..ac76f5b2 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -74,7 +74,7 @@ export const DEFAULT_CAPABILITIES: AccountCapabilities = { export function makeContract(overrides: Partial & { aliceId?: string } = {}): Contract { const c = new Contract() - c.aliceId = overrides.aliceId ?? 'mock-AAPL' + c.aliceId = overrides.aliceId ?? 'mock-paper|AAPL' c.symbol = overrides.symbol ?? 'AAPL' c.secType = overrides.secType ?? 'STK' c.exchange = overrides.exchange ?? 'MOCK' diff --git a/src/domain/trading/git/TradingGit.spec.ts b/src/domain/trading/git/TradingGit.spec.ts index e907ce2a..09722325 100644 --- a/src/domain/trading/git/TradingGit.spec.ts +++ b/src/domain/trading/git/TradingGit.spec.ts @@ -10,7 +10,7 @@ import '../contract-ext.js' function makeContract(overrides: { aliceId?: string; symbol?: string } = {}): Contract { const c = new Contract() - c.aliceId = overrides.aliceId ?? 'mock-AAPL' + c.aliceId = overrides.aliceId ?? 'mock-paper|AAPL' c.symbol = overrides.symbol ?? 'AAPL' c.secType = 'STK' c.exchange = 'NASDAQ' @@ -581,7 +581,7 @@ describe('TradingGit', () => { const stateWithPositions = makeGitState({ positions: [ { - contract: makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }), + contract: makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }), side: 'long', quantity: new Decimal(10), avgCost: 150, @@ -611,7 +611,7 @@ describe('TradingGit', () => { const stateWithPositions = makeGitState({ positions: [ { - contract: makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }), + contract: makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }), side: 'long', quantity: new Decimal(10), avgCost: 150, diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index 2f9b9e50..21f2521f 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -35,7 +35,7 @@ function tempPath(ext: string): string { } function makeSubmittedOrder(symbol = 'AAPL'): ReturnType { - const contract = makeContract({ symbol, aliceId: `mock-${symbol}` }) + const contract = makeContract({ symbol, aliceId: `mock-paper|${symbol}` }) const order = new Order() order.orderId = 42 order.action = 'BUY' @@ -93,7 +93,7 @@ describe('Snapshot Builder', () => { // #3 it('positions use aliceId, not full contract', async () => { - const pos = makePosition({ contract: makeContract({ symbol: 'TSLA', aliceId: 'mock-TSLA' }) }) + const pos = makePosition({ contract: makeContract({ symbol: 'TSLA', aliceId: 'mock-paper|TSLA' }) }) broker.setPositions([pos]) const snap = await buildSnapshot(uta, 'manual') expect(snap).not.toBeNull() diff --git a/src/tool/trading.ts b/src/tool/trading.ts index e4462dfc..907e8021 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -69,7 +69,7 @@ This is a BROKER-LEVEL search — it queries your connected trading accounts.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), symbol: z.string().optional().describe('Symbol to look up'), - aliceId: z.string().optional().describe('Alice contract ID for exact match'), + aliceId: z.string().optional().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), secType: z.string().optional().describe('Security type filter'), currency: z.string().optional().describe('Currency filter'), }), @@ -168,7 +168,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry description: `Query the latest quote/price for a contract. If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ - aliceId: z.string().describe('Contract identifier from searchContracts'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), source: z.string().optional().describe(sourceDesc(false)), }), execute: async ({ aliceId, source }) => { @@ -268,7 +268,7 @@ BEFORE placing orders: check tradingLog, getPortfolio, verify strategy alignment NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract identifier from searchContracts'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), side: z.enum(['buy', 'sell']).describe('Buy or sell'), type: z.enum(['market', 'limit', 'stop', 'stop_limit', 'trailing_stop', 'trailing_stop_limit', 'moc']).describe('Order type'), @@ -308,7 +308,7 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, description: 'Stage a position close.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract identifier'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), qty: z.number().positive().optional().describe('Number of shares to sell (default: sell all)'), }),