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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
26 changes: 11 additions & 15 deletions src/domain/trading/UnifiedTradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}

Expand All @@ -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()
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 5 additions & 4 deletions src/domain/trading/__test__/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 47 additions & 7 deletions src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down Expand Up @@ -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')
Expand Down
54 changes: 49 additions & 5 deletions src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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')
Expand Down
87 changes: 68 additions & 19 deletions src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -15,27 +14,77 @@ 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')
})

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()
Expand All @@ -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',
Expand Down Expand Up @@ -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}`)
Expand Down
Loading
Loading