Skip to content

Commit b9a2662

Browse files
authored
Merge pull request #82 from TraderAlice/dev
fix: IBKR contract identity + E2E improvements
2 parents 680cf40 + 86b6545 commit b9a2662

File tree

13 files changed

+362
-76
lines changed

13 files changed

+362
-76
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "open-alice",
3-
"version": "0.9.0-beta.6",
3+
"version": "0.9.0-beta.7",
44
"description": "File-based trading agent engine",
55
"type": "module",
66
"scripts": {

src/domain/trading/UnifiedTradingAccount.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,9 @@ export class UnifiedTradingAccount {
334334

335335
// ==================== aliceId management ====================
336336

337-
/** Construct aliceId: "{utaId}|{nativeKey}" */
337+
/** Construct aliceId: "{utaId}|{nativeKey}" using broker's native identity. */
338338
private stampAliceId(contract: Contract): void {
339-
const nativeKey = contract.localSymbol || contract.symbol || ''
339+
const nativeKey = this.broker.getNativeKey(contract)
340340
contract.aliceId = `${this.id}|${nativeKey}`
341341
}
342342

@@ -350,14 +350,12 @@ export class UnifiedTradingAccount {
350350
// ==================== Stage operations ====================
351351

352352
stagePlaceOrder(params: StagePlaceOrderParams): AddResult {
353-
const contract = new Contract()
354-
contract.aliceId = params.aliceId
355-
// Extract nativeKey from aliceId for broker resolution
353+
// Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.)
356354
const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId)
357-
if (parsed) {
358-
contract.symbol = parsed.nativeKey
359-
contract.localSymbol = parsed.nativeKey
360-
}
355+
const contract = parsed
356+
? this.broker.resolveNativeKey(parsed.nativeKey)
357+
: new Contract()
358+
contract.aliceId = params.aliceId
361359
if (params.symbol) contract.symbol = params.symbol
362360

363361
const order = new Order()
@@ -394,13 +392,11 @@ export class UnifiedTradingAccount {
394392
}
395393

396394
stageClosePosition(params: StageClosePositionParams): AddResult {
397-
const contract = new Contract()
398-
contract.aliceId = params.aliceId
399395
const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId)
400-
if (parsed) {
401-
contract.symbol = parsed.nativeKey
402-
contract.localSymbol = parsed.nativeKey
403-
}
396+
const contract = parsed
397+
? this.broker.resolveNativeKey(parsed.nativeKey)
398+
: new Contract()
399+
contract.aliceId = params.aliceId
404400
if (params.symbol) contract.symbol = params.symbol
405401

406402
return this.git.add({

src/domain/trading/__test__/e2e/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ it('places order', async ({ skip }) => {
5252
## Market Hours
5353

5454
- **Crypto (CCXT)**: 24/7, no market hours check needed
55-
- **Equities (Alpaca, IBKR)**: Split into two `describe` groups:
56-
- **Connectivity** — runs any time (getAccount, getPositions, searchContracts, getMarketClock)
57-
- **Trading** — requires market open (getQuote, placeOrder, closePosition)
55+
- **Equities (Alpaca, IBKR)**: Split into three `describe` groups:
56+
- **Connectivity** — any time (getAccount, getPositions, searchContracts, getMarketClock)
57+
- **Order lifecycle** — any time (limit order place → query → cancel — exchanges accept orders outside trading hours, they just don't fill)
58+
- **Fill + position** — market hours only (market order → fill → verify position → close)
5859

59-
Check `broker.getMarketClock().isOpen` in `beforeAll`, skip trading group via `beforeEach`.
60+
Check `broker.getMarketClock().isOpen` in `beforeAll`, skip fill group via `beforeEach`. Connectivity and order lifecycle always run.
6061

6162
## Setup
6263

src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
/**
22
* AlpacaBroker e2e — real orders against Alpaca paper trading.
33
*
4-
* Split into two groups:
5-
* - Connectivity tests: run any time (account info, positions, search, clock)
6-
* - Trading tests: only when market is open (quotes, orders, close)
7-
*
8-
* Preconditions handled in beforeEach — individual tests don't need skip checks.
4+
* Three groups:
5+
* - Connectivity: any time (account, positions, search, clock)
6+
* - Order lifecycle: any time (limit order place → query → cancel)
7+
* - Fill + position: market hours only (market order → fill → close)
98
*
109
* Run: pnpm test:e2e
1110
*/
@@ -68,9 +67,50 @@ describe('AlpacaBroker — connectivity', () => {
6867
})
6968
})
7069

71-
// ==================== Trading (market hours only) ====================
70+
// ==================== Order lifecycle (any time — limit orders accepted outside market hours) ====================
71+
72+
describe('AlpacaBroker — order lifecycle', () => {
73+
beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') })
74+
75+
it('places limit buy → queries → cancels', async () => {
76+
const contract = new Contract()
77+
contract.symbol = 'AAPL'
78+
contract.secType = 'STK'
79+
80+
// Place a limit buy at $1 — will never fill, safe to leave open briefly
81+
const order = new Order()
82+
order.action = 'BUY'
83+
order.orderType = 'LMT'
84+
order.lmtPrice = 1.00
85+
order.totalQuantity = new Decimal('1')
86+
order.tif = 'GTC'
87+
88+
const placed = await broker!.placeOrder(contract, order)
89+
console.log(` placeOrder LMT: success=${placed.success}, orderId=${placed.orderId}, status=${placed.orderState?.status}`)
90+
expect(placed.success).toBe(true)
91+
expect(placed.orderId).toBeDefined()
92+
93+
// Query order
94+
await new Promise(r => setTimeout(r, 1000))
95+
const detail = await broker!.getOrder(placed.orderId!)
96+
console.log(` getOrder: status=${detail?.orderState.status}`)
97+
expect(detail).not.toBeNull()
98+
99+
// Batch query
100+
const orders = await broker!.getOrders([placed.orderId!])
101+
console.log(` getOrders: ${orders.length} results`)
102+
expect(orders.length).toBe(1)
103+
104+
// Cancel
105+
const cancelled = await broker!.cancelOrder(placed.orderId!)
106+
console.log(` cancelOrder: success=${cancelled.success}, status=${cancelled.orderState?.status}`)
107+
expect(cancelled.success).toBe(true)
108+
}, 30_000)
109+
})
110+
111+
// ==================== Fill + position (market hours only) ====================
72112

73-
describe('AlpacaBroker — trading (market hours)', () => {
113+
describe('AlpacaBroker — fill + position (market hours)', () => {
74114
beforeEach(({ skip }) => {
75115
if (!broker) skip('no Alpaca paper account')
76116
if (!marketOpen) skip('market closed')

src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
22
* IbkrBroker e2e — real calls against TWS/IB Gateway paper trading.
33
*
4-
* Split into two groups:
5-
* - Connectivity tests: run any time (account info, positions, search, clock)
6-
* - Trading tests: only when market is open (quotes, orders, close)
4+
* Three groups:
5+
* - Connectivity: any time (account, positions, search, clock)
6+
* - Order lifecycle: any time (limit order place → query → cancel)
7+
* - Fill + position: market hours only (market order → fill → close)
78
*
89
* Requires TWS or IB Gateway running with paper trading enabled.
910
*
@@ -82,9 +83,52 @@ describe('IbkrBroker — connectivity', () => {
8283
})
8384
})
8485

85-
// ==================== Trading (market hours only) ====================
86+
// ==================== Order lifecycle (any time — limit orders accepted outside market hours) ====================
8687

87-
describe('IbkrBroker — trading (market hours)', () => {
88+
describe('IbkrBroker — order lifecycle', () => {
89+
beforeEach(({ skip }) => { if (!broker) skip('no IBKR paper account') })
90+
91+
it('places limit buy → queries → cancels', async () => {
92+
// Discover contract via searchContracts to get conId
93+
const results = await broker!.searchContracts('AAPL')
94+
expect(results.length).toBeGreaterThan(0)
95+
const contract = results[0].contract
96+
console.log(` resolved: symbol=${contract.symbol}, conId=${contract.conId}, secType=${contract.secType}`)
97+
98+
// Place a limit buy at $1 — will never fill, safe to leave open briefly
99+
const order = new Order()
100+
order.action = 'BUY'
101+
order.orderType = 'LMT'
102+
order.lmtPrice = 1.00
103+
order.totalQuantity = new Decimal('1')
104+
order.tif = 'GTC'
105+
106+
const placed = await broker!.placeOrder(contract, order)
107+
console.log(` placeOrder LMT: success=${placed.success}, orderId=${placed.orderId}, status=${placed.orderState?.status}`)
108+
expect(placed.success).toBe(true)
109+
expect(placed.orderId).toBeDefined()
110+
111+
// Query order
112+
await new Promise(r => setTimeout(r, 1000))
113+
const detail = await broker!.getOrder(placed.orderId!)
114+
console.log(` getOrder: status=${detail?.orderState.status}`)
115+
expect(detail).not.toBeNull()
116+
117+
// Batch query
118+
const orders = await broker!.getOrders([placed.orderId!])
119+
console.log(` getOrders: ${orders.length} results`)
120+
expect(orders.length).toBe(1)
121+
122+
// Cancel
123+
const cancelled = await broker!.cancelOrder(placed.orderId!)
124+
console.log(` cancelOrder: success=${cancelled.success}, status=${cancelled.orderState?.status}`)
125+
expect(cancelled.success).toBe(true)
126+
}, 30_000)
127+
})
128+
129+
// ==================== Fill + position (market hours only) ====================
130+
131+
describe('IbkrBroker — fill + position (market hours)', () => {
88132
beforeEach(({ skip }) => {
89133
if (!broker) skip('no IBKR paper account')
90134
if (!marketOpen) skip('market closed')

src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
/**
22
* UTA — Alpaca paper lifecycle e2e.
33
*
4-
* Full Trading-as-Git flow: stage → commit → push → sync → verify
5-
* against Alpaca paper trading (US equities).
6-
*
7-
* Skips when market is closed — Alpaca paper won't fill orders outside trading hours.
4+
* Two groups:
5+
* - Order lifecycle (any time): limit order stage → commit → push → cancel
6+
* - Full fill flow (market hours): market order → fill → verify → close
87
*
98
* Run: pnpm test:e2e
109
*/
@@ -15,27 +14,77 @@ import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js'
1514
import type { IBroker } from '../../brokers/types.js'
1615
import '../../contract-ext.js'
1716

18-
describe('UTA — Alpaca lifecycle (AAPL)', () => {
19-
let broker: IBroker | null = null
20-
let marketOpen = false
21-
22-
beforeAll(async () => {
23-
const all = await getTestAccounts()
24-
const alpaca = filterByProvider(all, 'alpaca')[0]
25-
if (!alpaca) return
26-
broker = alpaca.broker
27-
const clock = await broker.getMarketClock()
28-
marketOpen = clock.isOpen
29-
console.log(`UTA Alpaca: market ${marketOpen ? 'OPEN' : 'CLOSED'}`)
30-
}, 60_000)
17+
let broker: IBroker | null = null
18+
let marketOpen = false
19+
20+
beforeAll(async () => {
21+
const all = await getTestAccounts()
22+
const alpaca = filterByProvider(all, 'alpaca')[0]
23+
if (!alpaca) return
24+
broker = alpaca.broker
25+
const clock = await broker.getMarketClock()
26+
marketOpen = clock.isOpen
27+
console.log(`UTA Alpaca: market ${marketOpen ? 'OPEN' : 'CLOSED'}`)
28+
}, 60_000)
29+
30+
// ==================== Order lifecycle (any time) ====================
31+
32+
describe('UTA — Alpaca order lifecycle', () => {
33+
beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') })
34+
35+
it('limit order: stage → commit → push → cancel', async () => {
36+
const uta = new UnifiedTradingAccount(broker!)
37+
const nativeKey = broker!.getNativeKey({ symbol: 'AAPL' } as any)
38+
const aliceId = `${uta.id}|${nativeKey}`
39+
40+
// Stage a limit buy at $1 (won't fill)
41+
const addResult = uta.stagePlaceOrder({
42+
aliceId,
43+
symbol: 'AAPL',
44+
side: 'buy',
45+
type: 'limit',
46+
price: 1.00,
47+
qty: 1,
48+
timeInForce: 'gtc',
49+
})
50+
expect(addResult.staged).toBe(true)
51+
52+
const commitResult = uta.commit('e2e: limit buy 1 AAPL @ $1')
53+
expect(commitResult.prepared).toBe(true)
54+
console.log(` committed: hash=${commitResult.hash}`)
55+
56+
const pushResult = await uta.push()
57+
console.log(` pushed: submitted=${pushResult.submitted.length}, status=${pushResult.submitted[0]?.status}`)
58+
expect(pushResult.submitted).toHaveLength(1)
59+
expect(pushResult.rejected).toHaveLength(0)
60+
expect(pushResult.submitted[0].orderId).toBeDefined()
61+
62+
const orderId = pushResult.submitted[0].orderId!
63+
64+
// Cancel the order
65+
uta.stageCancelOrder({ orderId })
66+
uta.commit('e2e: cancel limit order')
67+
const cancelPush = await uta.push()
68+
console.log(` cancel pushed: submitted=${cancelPush.submitted.length}, status=${cancelPush.submitted[0]?.status}`)
69+
expect(cancelPush.submitted).toHaveLength(1)
70+
71+
// Verify log has 2 commits
72+
expect(uta.log().length).toBeGreaterThanOrEqual(2)
73+
}, 30_000)
74+
})
75+
76+
// ==================== Full fill flow (market hours only) ====================
3177

78+
describe('UTA — Alpaca fill flow (AAPL)', () => {
3279
beforeEach(({ skip }) => {
3380
if (!broker) skip('no Alpaca paper account')
3481
if (!marketOpen) skip('market closed')
3582
})
3683

3784
it('buy → sync → verify → close → sync → verify', async () => {
3885
const uta = new UnifiedTradingAccount(broker!)
86+
const nativeKey = broker!.getNativeKey({ symbol: 'AAPL' } as any)
87+
const aliceId = `${uta.id}|${nativeKey}`
3988

4089
// Record initial state
4190
const initialPositions = await broker!.getPositions()
@@ -44,7 +93,7 @@ describe('UTA — Alpaca lifecycle (AAPL)', () => {
4493

4594
// === Stage + Commit + Push: buy 1 AAPL ===
4695
const addResult = uta.stagePlaceOrder({
47-
aliceId: `${uta.id}|AAPL`,
96+
aliceId,
4897
symbol: 'AAPL',
4998
side: 'buy',
5099
type: 'market',
@@ -79,7 +128,7 @@ describe('UTA — Alpaca lifecycle (AAPL)', () => {
79128
expect(aaplPos!.quantity.toNumber()).toBe(initialAaplQty + 1)
80129

81130
// === Close 1 AAPL ===
82-
uta.stageClosePosition({ aliceId: `${uta.id}|AAPL`, qty: 1 })
131+
uta.stageClosePosition({ aliceId, qty: 1 })
83132
uta.commit('e2e: close 1 AAPL')
84133
const closePush = await uta.push()
85134
console.log(` close pushed: status=${closePush.submitted[0]?.status}`)

0 commit comments

Comments
 (0)