diff --git a/package.json b/package.json index fd04df43..4eff78bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.9.0-beta.6", + "version": "0.9.0-beta.7", "description": "File-based trading agent engine", "type": "module", "scripts": { diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index eb70987d..52a0c634 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -334,9 +334,9 @@ export class UnifiedTradingAccount { // ==================== aliceId management ==================== - /** Construct aliceId: "{utaId}|{nativeKey}" */ + /** Construct aliceId: "{utaId}|{nativeKey}" using broker's native identity. */ private stampAliceId(contract: Contract): void { - const nativeKey = contract.localSymbol || contract.symbol || '' + const nativeKey = this.broker.getNativeKey(contract) contract.aliceId = `${this.id}|${nativeKey}` } @@ -350,14 +350,12 @@ export class UnifiedTradingAccount { // ==================== Stage operations ==================== stagePlaceOrder(params: StagePlaceOrderParams): AddResult { - const contract = new Contract() - contract.aliceId = params.aliceId - // Extract nativeKey from aliceId for broker resolution + // Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.) const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) - if (parsed) { - contract.symbol = parsed.nativeKey - contract.localSymbol = parsed.nativeKey - } + const contract = parsed + ? this.broker.resolveNativeKey(parsed.nativeKey) + : new Contract() + contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol const order = new Order() @@ -394,13 +392,11 @@ export class UnifiedTradingAccount { } stageClosePosition(params: StageClosePositionParams): AddResult { - const contract = new Contract() - contract.aliceId = params.aliceId const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) - if (parsed) { - contract.symbol = parsed.nativeKey - contract.localSymbol = parsed.nativeKey - } + const contract = parsed + ? this.broker.resolveNativeKey(parsed.nativeKey) + : new Contract() + contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol return this.git.add({ diff --git a/src/domain/trading/__test__/e2e/README.md b/src/domain/trading/__test__/e2e/README.md index 56c2c426..67c6d561 100644 --- a/src/domain/trading/__test__/e2e/README.md +++ b/src/domain/trading/__test__/e2e/README.md @@ -52,11 +52,12 @@ it('places order', async ({ skip }) => { ## Market Hours - **Crypto (CCXT)**: 24/7, no market hours check needed -- **Equities (Alpaca, IBKR)**: Split into two `describe` groups: - - **Connectivity** — runs any time (getAccount, getPositions, searchContracts, getMarketClock) - - **Trading** — requires market open (getQuote, placeOrder, closePosition) +- **Equities (Alpaca, IBKR)**: Split into three `describe` groups: + - **Connectivity** — any time (getAccount, getPositions, searchContracts, getMarketClock) + - **Order lifecycle** — any time (limit order place → query → cancel — exchanges accept orders outside trading hours, they just don't fill) + - **Fill + position** — market hours only (market order → fill → verify position → close) -Check `broker.getMarketClock().isOpen` in `beforeAll`, skip trading group via `beforeEach`. +Check `broker.getMarketClock().isOpen` in `beforeAll`, skip fill group via `beforeEach`. Connectivity and order lifecycle always run. ## Setup 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 ac680931..f9b5b23d 100644 --- a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts @@ -1,11 +1,10 @@ /** * AlpacaBroker e2e — real orders against Alpaca paper trading. * - * Split into two groups: - * - Connectivity tests: run any time (account info, positions, search, clock) - * - Trading tests: only when market is open (quotes, orders, close) - * - * Preconditions handled in beforeEach — individual tests don't need skip checks. + * Three groups: + * - Connectivity: any time (account, positions, search, clock) + * - Order lifecycle: any time (limit order place → query → cancel) + * - Fill + position: market hours only (market order → fill → close) * * Run: pnpm test:e2e */ @@ -68,9 +67,50 @@ describe('AlpacaBroker — connectivity', () => { }) }) -// ==================== Trading (market hours only) ==================== +// ==================== Order lifecycle (any time — limit orders accepted outside market hours) ==================== + +describe('AlpacaBroker — order lifecycle', () => { + beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') }) + + it('places limit buy → queries → cancels', async () => { + const contract = new Contract() + contract.symbol = 'AAPL' + contract.secType = 'STK' + + // Place a limit buy at $1 — will never fill, safe to leave open briefly + const order = new Order() + order.action = 'BUY' + order.orderType = 'LMT' + order.lmtPrice = 1.00 + order.totalQuantity = new Decimal('1') + order.tif = 'GTC' + + const placed = await broker!.placeOrder(contract, order) + console.log(` placeOrder LMT: success=${placed.success}, orderId=${placed.orderId}, status=${placed.orderState?.status}`) + expect(placed.success).toBe(true) + expect(placed.orderId).toBeDefined() + + // Query order + await new Promise(r => setTimeout(r, 1000)) + const detail = await broker!.getOrder(placed.orderId!) + console.log(` getOrder: status=${detail?.orderState.status}`) + expect(detail).not.toBeNull() + + // Batch query + const orders = await broker!.getOrders([placed.orderId!]) + console.log(` getOrders: ${orders.length} results`) + expect(orders.length).toBe(1) + + // Cancel + const cancelled = await broker!.cancelOrder(placed.orderId!) + console.log(` cancelOrder: success=${cancelled.success}, status=${cancelled.orderState?.status}`) + expect(cancelled.success).toBe(true) + }, 30_000) +}) + +// ==================== Fill + position (market hours only) ==================== -describe('AlpacaBroker — trading (market hours)', () => { +describe('AlpacaBroker — fill + position (market hours)', () => { beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') if (!marketOpen) skip('market closed') diff --git a/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts b/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts index 921477f3..da20bc30 100644 --- a/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts @@ -1,9 +1,10 @@ /** * IbkrBroker e2e — real calls against TWS/IB Gateway paper trading. * - * Split into two groups: - * - Connectivity tests: run any time (account info, positions, search, clock) - * - Trading tests: only when market is open (quotes, orders, close) + * Three groups: + * - Connectivity: any time (account, positions, search, clock) + * - Order lifecycle: any time (limit order place → query → cancel) + * - Fill + position: market hours only (market order → fill → close) * * Requires TWS or IB Gateway running with paper trading enabled. * @@ -82,9 +83,52 @@ describe('IbkrBroker — connectivity', () => { }) }) -// ==================== Trading (market hours only) ==================== +// ==================== Order lifecycle (any time — limit orders accepted outside market hours) ==================== -describe('IbkrBroker — trading (market hours)', () => { +describe('IbkrBroker — order lifecycle', () => { + beforeEach(({ skip }) => { if (!broker) skip('no IBKR paper account') }) + + it('places limit buy → queries → cancels', async () => { + // Discover contract via searchContracts to get conId + const results = await broker!.searchContracts('AAPL') + expect(results.length).toBeGreaterThan(0) + const contract = results[0].contract + console.log(` resolved: symbol=${contract.symbol}, conId=${contract.conId}, secType=${contract.secType}`) + + // Place a limit buy at $1 — will never fill, safe to leave open briefly + const order = new Order() + order.action = 'BUY' + order.orderType = 'LMT' + order.lmtPrice = 1.00 + order.totalQuantity = new Decimal('1') + order.tif = 'GTC' + + const placed = await broker!.placeOrder(contract, order) + console.log(` placeOrder LMT: success=${placed.success}, orderId=${placed.orderId}, status=${placed.orderState?.status}`) + expect(placed.success).toBe(true) + expect(placed.orderId).toBeDefined() + + // Query order + await new Promise(r => setTimeout(r, 1000)) + const detail = await broker!.getOrder(placed.orderId!) + console.log(` getOrder: status=${detail?.orderState.status}`) + expect(detail).not.toBeNull() + + // Batch query + const orders = await broker!.getOrders([placed.orderId!]) + console.log(` getOrders: ${orders.length} results`) + expect(orders.length).toBe(1) + + // Cancel + const cancelled = await broker!.cancelOrder(placed.orderId!) + console.log(` cancelOrder: success=${cancelled.success}, status=${cancelled.orderState?.status}`) + expect(cancelled.success).toBe(true) + }, 30_000) +}) + +// ==================== Fill + position (market hours only) ==================== + +describe('IbkrBroker — fill + position (market hours)', () => { beforeEach(({ skip }) => { if (!broker) skip('no IBKR paper account') if (!marketOpen) skip('market closed') diff --git a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts index 0cd3b1cb..61edf3fc 100644 --- a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts @@ -1,10 +1,9 @@ /** * UTA — Alpaca paper lifecycle e2e. * - * Full Trading-as-Git flow: stage → commit → push → sync → verify - * against Alpaca paper trading (US equities). - * - * Skips when market is closed — Alpaca paper won't fill orders outside trading hours. + * Two groups: + * - Order lifecycle (any time): limit order stage → commit → push → cancel + * - Full fill flow (market hours): market order → fill → verify → close * * Run: pnpm test:e2e */ @@ -15,20 +14,68 @@ import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' import type { IBroker } from '../../brokers/types.js' import '../../contract-ext.js' -describe('UTA — Alpaca lifecycle (AAPL)', () => { - let broker: IBroker | null = null - let marketOpen = false - - beforeAll(async () => { - const all = await getTestAccounts() - const alpaca = filterByProvider(all, 'alpaca')[0] - if (!alpaca) return - broker = alpaca.broker - const clock = await broker.getMarketClock() - marketOpen = clock.isOpen - console.log(`UTA Alpaca: market ${marketOpen ? 'OPEN' : 'CLOSED'}`) - }, 60_000) +let broker: IBroker | null = null +let marketOpen = false + +beforeAll(async () => { + const all = await getTestAccounts() + const alpaca = filterByProvider(all, 'alpaca')[0] + if (!alpaca) return + broker = alpaca.broker + const clock = await broker.getMarketClock() + marketOpen = clock.isOpen + console.log(`UTA Alpaca: market ${marketOpen ? 'OPEN' : 'CLOSED'}`) +}, 60_000) + +// ==================== Order lifecycle (any time) ==================== + +describe('UTA — Alpaca order lifecycle', () => { + beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') }) + + it('limit order: stage → commit → push → cancel', async () => { + const uta = new UnifiedTradingAccount(broker!) + const nativeKey = broker!.getNativeKey({ symbol: 'AAPL' } as any) + const aliceId = `${uta.id}|${nativeKey}` + + // Stage a limit buy at $1 (won't fill) + const addResult = uta.stagePlaceOrder({ + aliceId, + symbol: 'AAPL', + side: 'buy', + type: 'limit', + price: 1.00, + qty: 1, + timeInForce: 'gtc', + }) + expect(addResult.staged).toBe(true) + + const commitResult = uta.commit('e2e: limit buy 1 AAPL @ $1') + expect(commitResult.prepared).toBe(true) + console.log(` committed: hash=${commitResult.hash}`) + + const pushResult = await uta.push() + console.log(` pushed: submitted=${pushResult.submitted.length}, status=${pushResult.submitted[0]?.status}`) + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + expect(pushResult.submitted[0].orderId).toBeDefined() + + const orderId = pushResult.submitted[0].orderId! + + // Cancel the order + uta.stageCancelOrder({ orderId }) + uta.commit('e2e: cancel limit order') + const cancelPush = await uta.push() + console.log(` cancel pushed: submitted=${cancelPush.submitted.length}, status=${cancelPush.submitted[0]?.status}`) + expect(cancelPush.submitted).toHaveLength(1) + + // Verify log has 2 commits + expect(uta.log().length).toBeGreaterThanOrEqual(2) + }, 30_000) +}) + +// ==================== Full fill flow (market hours only) ==================== +describe('UTA — Alpaca fill flow (AAPL)', () => { beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') if (!marketOpen) skip('market closed') @@ -36,6 +83,8 @@ describe('UTA — Alpaca lifecycle (AAPL)', () => { it('buy → sync → verify → close → sync → verify', async () => { const uta = new UnifiedTradingAccount(broker!) + const nativeKey = broker!.getNativeKey({ symbol: 'AAPL' } as any) + const aliceId = `${uta.id}|${nativeKey}` // Record initial state const initialPositions = await broker!.getPositions() @@ -44,7 +93,7 @@ describe('UTA — Alpaca lifecycle (AAPL)', () => { // === Stage + Commit + Push: buy 1 AAPL === const addResult = uta.stagePlaceOrder({ - aliceId: `${uta.id}|AAPL`, + aliceId, symbol: 'AAPL', side: 'buy', type: 'market', @@ -79,7 +128,7 @@ describe('UTA — Alpaca lifecycle (AAPL)', () => { expect(aaplPos!.quantity.toNumber()).toBe(initialAaplQty + 1) // === Close 1 AAPL === - uta.stageClosePosition({ aliceId: `${uta.id}|AAPL`, qty: 1 }) + uta.stageClosePosition({ aliceId, qty: 1 }) uta.commit('e2e: close 1 AAPL') const closePush = await uta.push() console.log(` close pushed: status=${closePush.submitted[0]?.status}`) diff --git a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts index 21c1eb4f..b551eb9c 100644 --- a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts @@ -1,10 +1,9 @@ /** * UTA — IBKR paper lifecycle e2e. * - * Full Trading-as-Git flow: stage → commit → push → sync → verify - * against IBKR paper trading (US equities via TWS/Gateway). - * - * Skips when market is closed — TWS paper won't fill orders outside trading hours. + * Two groups: + * - Order lifecycle (any time): limit order stage → commit → push → cancel + * - Full fill flow (market hours): market order → fill → verify → close * * Run: pnpm test:e2e */ @@ -15,20 +14,73 @@ import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' import type { IBroker } from '../../brokers/types.js' import '../../contract-ext.js' -describe('UTA — IBKR lifecycle (AAPL)', () => { - let broker: IBroker | null = null - let marketOpen = false - - beforeAll(async () => { - const all = await getTestAccounts() - const ibkr = filterByProvider(all, 'ibkr')[0] - if (!ibkr) return - broker = ibkr.broker - const clock = await broker.getMarketClock() - marketOpen = clock.isOpen - console.log(`UTA IBKR: market ${marketOpen ? 'OPEN' : 'CLOSED'}`) - }, 60_000) +let broker: IBroker | null = null +let marketOpen = false + +beforeAll(async () => { + const all = await getTestAccounts() + const ibkr = filterByProvider(all, 'ibkr')[0] + if (!ibkr) return + broker = ibkr.broker + const clock = await broker.getMarketClock() + marketOpen = clock.isOpen + console.log(`UTA IBKR: market ${marketOpen ? 'OPEN' : 'CLOSED'}`) +}, 60_000) + +// ==================== Order lifecycle (any time) ==================== + +describe('UTA — IBKR order lifecycle', () => { + beforeEach(({ skip }) => { if (!broker) skip('no IBKR paper account') }) + + it('limit order: stage → commit → push → cancel', async () => { + const uta = new UnifiedTradingAccount(broker!) + + // Discover AAPL contract to get conId-based aliceId + const results = await broker!.searchContracts('AAPL') + expect(results.length).toBeGreaterThan(0) + const nativeKey = broker!.getNativeKey(results[0].contract) + const aliceId = `${uta.id}|${nativeKey}` + console.log(` resolved: nativeKey=${nativeKey}, aliceId=${aliceId}`) + // Stage a limit buy at $1 (won't fill) + const addResult = uta.stagePlaceOrder({ + aliceId, + symbol: 'AAPL', + side: 'buy', + type: 'limit', + price: 1.00, + qty: 1, + timeInForce: 'gtc', + }) + expect(addResult.staged).toBe(true) + + const commitResult = uta.commit('e2e: limit buy 1 AAPL @ $1') + expect(commitResult.prepared).toBe(true) + console.log(` committed: hash=${commitResult.hash}`) + + const pushResult = await uta.push() + console.log(` pushed: submitted=${pushResult.submitted.length}, status=${pushResult.submitted[0]?.status}`) + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + expect(pushResult.submitted[0].orderId).toBeDefined() + + const orderId = pushResult.submitted[0].orderId! + + // Cancel the order + uta.stageCancelOrder({ orderId }) + uta.commit('e2e: cancel limit order') + const cancelPush = await uta.push() + console.log(` cancel pushed: submitted=${cancelPush.submitted.length}, status=${cancelPush.submitted[0]?.status}`) + expect(cancelPush.submitted).toHaveLength(1) + + // Verify log has 2 commits + expect(uta.log().length).toBeGreaterThanOrEqual(2) + }, 30_000) +}) + +// ==================== Full fill flow (market hours only) ==================== + +describe('UTA — IBKR fill flow (AAPL)', () => { beforeEach(({ skip }) => { if (!broker) skip('no IBKR paper account') if (!marketOpen) skip('market closed') @@ -37,6 +89,11 @@ describe('UTA — IBKR lifecycle (AAPL)', () => { it('buy → sync → verify → close → sync → verify', async () => { const uta = new UnifiedTradingAccount(broker!) + // Discover AAPL contract to get conId-based aliceId + const results = await broker!.searchContracts('AAPL') + const nativeKey = broker!.getNativeKey(results[0].contract) + const aliceId = `${uta.id}|${nativeKey}` + // Record initial state const initialPositions = await broker!.getPositions() const initialAaplQty = initialPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 @@ -44,7 +101,7 @@ describe('UTA — IBKR lifecycle (AAPL)', () => { // === Stage + Commit + Push: buy 1 AAPL === const addResult = uta.stagePlaceOrder({ - aliceId: `${uta.id}|AAPL`, + aliceId, symbol: 'AAPL', side: 'buy', type: 'market', @@ -79,7 +136,7 @@ describe('UTA — IBKR lifecycle (AAPL)', () => { expect(aaplPos!.quantity.toNumber()).toBe(initialAaplQty + 1) // === Close 1 AAPL === - uta.stageClosePosition({ aliceId: `${uta.id}|AAPL`, qty: 1 }) + uta.stageClosePosition({ aliceId, qty: 1 }) uta.commit('e2e: close 1 AAPL') const closePush = await uta.push() console.log(` close pushed: status=${closePush.submitted[0]?.status}`) diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index 3ef70914..cfda3ba2 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -367,6 +367,16 @@ export class AlpacaBroker implements IBroker { } + // ---- Contract identity ---- + + getNativeKey(contract: Contract): string { + return contract.symbol + } + + resolveNativeKey(nativeKey: string): Contract { + return makeContract(nativeKey) + } + // ---- Internal ---- private mapOpenOrder(o: AlpacaOrderRaw): OpenOrder { diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 70ee1740..e1716cdc 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -540,6 +540,22 @@ export class CcxtBroker implements IBroker { } } + // ---- Contract identity ---- + + getNativeKey(contract: Contract): string { + return contract.localSymbol || contract.symbol + } + + resolveNativeKey(nativeKey: string): Contract { + const market = this.markets[nativeKey] + if (market) return marketToContract(market, this.exchange.id) + // Fallback: construct minimal contract from symbol string + const c = new Contract() + c.localSymbol = nativeKey + c.symbol = nativeKey.split('/')[0] ?? nativeKey + return c + } + // ---- Provider-specific methods ---- async getFundingRate(contract: Contract): Promise { diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 8e459d74..ad39239e 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -97,6 +97,9 @@ export class IbkrBroker implements IBroker { } async getContractDetails(query: Contract): Promise { + if (!query.exchange) query.exchange = 'SMART' + if (!query.currency) query.currency = 'USD' + const reqId = this.bridge.allocReqId() const promise = this.bridge.requestCollector(reqId) this.client.reqContractDetails(reqId, query) @@ -107,6 +110,14 @@ export class IbkrBroker implements IBroker { // ==================== Trading operations ==================== async placeOrder(contract: Contract, order: Order): Promise { + // 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 { const orderId = this.bridge.getNextOrderId() const promise = this.bridge.requestOrder(orderId) @@ -233,6 +244,9 @@ export class IbkrBroker implements IBroker { } async getQuote(contract: Contract): Promise { + if (!contract.exchange) contract.exchange = 'SMART' + if (!contract.currency) contract.currency = 'USD' + const reqId = this.bridge.allocReqId() const promise = this.bridge.requestSnapshot(reqId) this.client.reqMktData(reqId, contract, '', true, false, []) @@ -251,8 +265,15 @@ export class IbkrBroker implements IBroker { } async getMarketClock(): Promise { - const serverTime = await this.bridge.requestCurrentTime() - const now = new Date(serverTime * 1000) + // TODO: per-contract trading hours via ContractDetails.tradingHours + // For now, use local time with NYSE schedule as a baseline. + let now: Date + try { + const serverTime = await this.bridge.requestCurrentTime(3000) + now = new Date(serverTime * 1000) + } catch { + now = new Date() + } // NYSE hours: Mon-Fri 9:30-16:00 ET const etParts = new Intl.DateTimeFormat('en-US', { @@ -283,6 +304,31 @@ export class IbkrBroker implements IBroker { } } + // ==================== Contract identity ==================== + + getNativeKey(contract: Contract): string { + // conId is IBKR's globally unique contract identifier + if (contract.conId) return String(contract.conId) + return contract.symbol + } + + resolveNativeKey(nativeKey: string): Contract { + const c = new Contract() + const asNum = parseInt(nativeKey, 10) + if (!isNaN(asNum) && String(asNum) === nativeKey) { + // Numeric nativeKey = conId — TWS resolves everything else from this + c.conId = asNum + } else { + // String nativeKey = symbol — fill in routing defaults. + // Assumes STK; other secTypes should use conId for unambiguous resolution. + c.symbol = nativeKey + c.secType = 'STK' + c.exchange = 'SMART' + c.currency = 'USD' + } + return c + } + // ==================== Internal ==================== private downloadAccount(): Promise { diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index 6c960485..dcfb9701 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -386,6 +386,19 @@ export class MockBroker implements IBroker { return DEFAULT_CAPABILITIES } + // ==================== Contract identity ==================== + + getNativeKey(contract: Contract): string { + return contract.symbol + } + + resolveNativeKey(nativeKey: string): Contract { + const c = new Contract() + c.symbol = nativeKey + c.secType = 'STK' + return c + } + // ==================== Test helpers ==================== /** Inject a quote for a symbol. Used to control fill prices for market orders. */ diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index ae775747..263a67c4 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -191,4 +191,14 @@ export interface IBroker { // ---- Capabilities ---- getCapabilities(): AccountCapabilities + + // ---- Contract identity ---- + + /** Extract the broker-native unique key from a contract (for aliceId construction). + * Each broker defines its own uniqueness: Alpaca = ticker, CCXT = unified symbol, IBKR = conId. */ + getNativeKey(contract: Contract): string + + /** Reconstruct a trade-ready contract from a nativeKey (for aliceId resolution). + * Broker fills in secType, exchange, currency, conId, etc. as needed. */ + resolveNativeKey(nativeKey: string): Contract } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 6f412c40..2b18bbf0 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,10 +1,12 @@ -import { defineConfig } from 'vitest/config' import { fileURLToPath } from 'node:url' import { resolve, dirname } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) -export default defineConfig({ +// Single process, sequential execution. E2E tests share stateful broker +// connections (IBKR TCP + clientId, REST API sessions). Module-level +// singletons in setup.ts require same-process to actually share state. +export default { resolve: { alias: { '@': resolve(__dirname, './src'), @@ -13,6 +15,8 @@ export default defineConfig({ test: { include: ['src/**/*.e2e.spec.*'], testTimeout: 60_000, - fileParallelism: false, // e2e tests share exchange APIs — run sequentially + fileParallelism: false, + pool: 'forks', + poolOptions: { forks: { singleFork: true } }, }, -}) +}