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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,11 @@ Centralized registry. `tool/` files register tools via `ToolCenter.register()`,
- `dev` branch for all development, `master` only via PR
- **Never** force push master, **never** push `archive/dev` (contains old API keys)
- CLAUDE.md is **committed to the repo and publicly visible** — never put API keys, personal paths, or sensitive information in it

### Branch Safety Rules

- **NEVER delete `dev` or `master` branches** — both are protected on GitHub (`allow_deletions: false`, `allow_force_pushes: false`)
- When merging PRs, **NEVER use `--delete-branch`** — it deletes the source branch and destroys commit history
- When merging PRs, **prefer `--merge` over `--squash`** — squash destroys individual commit history. If the PR has clean, meaningful commits, merge them as-is
- If squash is needed (messy history), do it — but never combine with `--delete-branch`
- `archive/dev-pre-beta6` is a historical snapshot — do not modify or delete
6 changes: 3 additions & 3 deletions src/domain/trading/UnifiedTradingAccount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe('UTA — getState', () => {
broker.setPositions([makePosition()])

// Push a limit order to create a pending entry in git history
uta.stagePlaceOrder({ aliceId: 'mock-AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 5, price: 145 })
uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 5, price: 145 })
uta.commit('limit buy')
await uta.push()

Expand Down Expand Up @@ -435,7 +435,7 @@ describe('UTA — sync', () => {
const { uta, broker } = createUTA()

// Limit order → MockBroker keeps it pending naturally
uta.stagePlaceOrder({ aliceId: 'mock-AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 150 })
uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 150 })
uta.commit('limit buy')
const pushResult = await uta.push()
const orderId = pushResult.submitted[0]?.orderId
Expand All @@ -454,7 +454,7 @@ describe('UTA — sync', () => {
const { uta, broker } = createUTA()

// Limit order → pending
uta.stagePlaceOrder({ aliceId: 'mock-AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 150 })
uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 150 })
uta.commit('limit buy')
const pushResult = await uta.push()
const orderId = pushResult.submitted[0]?.orderId
Expand Down
61 changes: 49 additions & 12 deletions src/domain/trading/UnifiedTradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export interface StageClosePositionParams {
export class UnifiedTradingAccount {
readonly id: string
readonly label: string
readonly provider: string
readonly broker: IBroker
readonly git: TradingGit
readonly platformId?: string
Expand All @@ -113,7 +112,6 @@ export class UnifiedTradingAccount {
this.broker = broker
this.id = broker.id
this.label = broker.label
this.provider = broker.provider
this.platformId = options.platformId

// Wire internals
Expand All @@ -124,6 +122,9 @@ export class UnifiedTradingAccount {
broker.getPositions(),
broker.getOrders(pendingIds),
])
// Stamp aliceId on all contracts returned by broker
for (const p of positions) this.stampAliceId(p.contract)
for (const o of orders) this.stampAliceId(o.contract)
return {
netLiquidation: accountInfo.netLiquidation,
totalCashValue: accountInfo.totalCashValue,
Expand Down Expand Up @@ -162,11 +163,32 @@ export class UnifiedTradingAccount {
: new TradingGit(gitConfig)
}

// ==================== aliceId management ====================

/** Construct aliceId: "{utaId}|{nativeKey}" */
private stampAliceId(contract: Contract): void {
const nativeKey = contract.localSymbol || contract.symbol || ''
contract.aliceId = `${this.id}|${nativeKey}`
}

/** Parse aliceId → { utaId, nativeKey }, or null if invalid. */
static parseAliceId(aliceId: string): { utaId: string; nativeKey: string } | null {
const sep = aliceId.indexOf('|')
if (sep === -1) return null
return { utaId: aliceId.slice(0, sep), nativeKey: aliceId.slice(sep + 1) }
}

// ==================== Stage operations ====================

stagePlaceOrder(params: StagePlaceOrderParams): AddResult {
const contract = new Contract()
contract.aliceId = params.aliceId
// Extract nativeKey from aliceId for broker resolution
const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId)
if (parsed) {
contract.symbol = parsed.nativeKey
contract.localSymbol = parsed.nativeKey
}
if (params.symbol) contract.symbol = params.symbol

const order = new Order()
Expand Down Expand Up @@ -205,6 +227,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
}
if (params.symbol) contract.symbol = params.symbol

return this.git.add({
Expand Down Expand Up @@ -294,28 +321,38 @@ export class UnifiedTradingAccount {
return this.broker.getAccount()
}

getPositions(): Promise<Position[]> {
return this.broker.getPositions()
async getPositions(): Promise<Position[]> {
const positions = await this.broker.getPositions()
for (const p of positions) this.stampAliceId(p.contract)
return positions
}

getOrders(orderIds: string[]): Promise<OpenOrder[]> {
return this.broker.getOrders(orderIds)
async getOrders(orderIds: string[]): Promise<OpenOrder[]> {
const orders = await this.broker.getOrders(orderIds)
for (const o of orders) this.stampAliceId(o.contract)
return orders
}

getQuote(contract: Contract): Promise<Quote> {
return this.broker.getQuote(contract)
async getQuote(contract: Contract): Promise<Quote> {
const quote = await this.broker.getQuote(contract)
this.stampAliceId(quote.contract)
return quote
}

getMarketClock(): Promise<MarketClock> {
return this.broker.getMarketClock()
}

searchContracts(pattern: string): Promise<ContractDescription[]> {
return this.broker.searchContracts(pattern)
async searchContracts(pattern: string): Promise<ContractDescription[]> {
const results = await this.broker.searchContracts(pattern)
for (const desc of results) this.stampAliceId(desc.contract)
return results
}

getContractDetails(query: Contract): Promise<ContractDetails | null> {
return this.broker.getContractDetails(query)
async getContractDetails(query: Contract): Promise<ContractDetails | null> {
const details = await this.broker.getContractDetails(query)
if (details) this.stampAliceId(details.contract)
return details
}

getCapabilities(): AccountCapabilities {
Expand Down
216 changes: 216 additions & 0 deletions src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* AlpacaBroker e2e — real orders against Alpaca paper trading.
*
* Reads Alice's config, picks the first Alpaca paper account.
* If none configured, entire suite skips.
*
* Run: pnpm test:e2e
*/

import { describe, it, expect, beforeAll } from 'vitest'
import Decimal from 'decimal.js'
import { Contract, Order } from '@traderalice/ibkr'
import { getTestAccounts, filterByProvider } from './setup.js'
import type { IBroker } from '../../brokers/types.js'
import '../../contract-ext.js'

let broker: IBroker | null = null

beforeAll(async () => {
const all = await getTestAccounts()
const alpaca = filterByProvider(all, 'alpaca')[0]
if (!alpaca) {
console.log('e2e: No Alpaca paper account configured, skipping')
return
}
broker = alpaca.broker
console.log(`e2e: ${alpaca.label} connected`)
}, 60_000)

describe('AlpacaBroker — Paper e2e', () => {
it('has a configured Alpaca paper account (or skips entire suite)', () => {
if (!broker) {
console.log('e2e: skipped — no Alpaca paper account')
return
}
expect(broker).toBeDefined()
})

it('fetches account info with positive equity', async () => {
if (!broker) return
const account = await broker.getAccount()
expect(account.netLiquidation).toBeGreaterThan(0)
expect(account.totalCashValue).toBeGreaterThan(0)
console.log(` equity: $${account.netLiquidation.toFixed(2)}, cash: $${account.totalCashValue.toFixed(2)}, buying_power: $${account.buyingPower?.toFixed(2)}`)
console.log(` unrealizedPnL: $${account.unrealizedPnL}, realizedPnL: $${account.realizedPnL}, dayTrades: ${account.dayTradesRemaining}`)
})

it('fetches market clock', async () => {
if (!broker) return
const clock = await broker.getMarketClock()
expect(typeof clock.isOpen).toBe('boolean')
console.log(` isOpen: ${clock.isOpen}, nextOpen: ${clock.nextOpen?.toISOString()}, nextClose: ${clock.nextClose?.toISOString()}`)
})

it('searches AAPL contracts', async () => {
if (!broker) return
const results = await broker.searchContracts('AAPL')
expect(results.length).toBeGreaterThan(0)
expect(results[0].contract.symbol).toBe('AAPL')
// Broker no longer sets aliceId — that's UTA's job
expect(results[0].contract.aliceId).toBeUndefined()
console.log(` found: ${results[0].contract.symbol}, secType: ${results[0].contract.secType}`)
})

it('fetches AAPL quote with valid prices', async () => {
if (!broker) return
const contract = new Contract()
contract.aliceId = 'alpaca-AAPL'
contract.symbol = 'AAPL'

const quote = await broker.getQuote(contract)
expect(quote.last).toBeGreaterThan(0)
expect(quote.bid).toBeGreaterThan(0)
expect(quote.ask).toBeGreaterThan(0)
expect(quote.volume).toBeGreaterThan(0)
console.log(` AAPL: last=$${quote.last}, bid=$${quote.bid}, ask=$${quote.ask}, vol=${quote.volume}`)
})

it('places market buy 1 AAPL → success with UUID orderId', async () => {
if (!broker) return

const contract = new Contract()
contract.aliceId = 'alpaca-AAPL'
contract.symbol = 'AAPL'
contract.secType = 'STK'

const order = new Order()
order.action = 'BUY'
order.orderType = 'MKT'
order.totalQuantity = new Decimal('1')
order.tif = 'DAY'

const result = await broker.placeOrder(contract, order)
console.log(` placeOrder raw:`, JSON.stringify({
success: result.success,
orderId: result.orderId,
orderState: result.orderState?.status,
error: result.error,
}))

expect(result.success).toBe(true)
expect(result.orderId).toBeDefined()
// Alpaca order IDs are UUIDs like "b0b6dd9d-8b9b-..."
expect(result.orderId!.length).toBeGreaterThan(10)
console.log(` orderId: ${result.orderId} (length=${result.orderId!.length})`)
}, 15_000)

it('queries order by ID after place', async () => {
if (!broker) return

// Place a fresh order to get an ID
const contract = new Contract()
contract.aliceId = 'alpaca-AAPL'
contract.symbol = 'AAPL'
contract.secType = 'STK'

const order = new Order()
order.action = 'BUY'
order.orderType = 'MKT'
order.totalQuantity = new Decimal('1')
order.tif = 'DAY'

const placed = await broker.placeOrder(contract, order)
if (!placed.orderId) { console.log(' no orderId returned, skipping'); return }

// Wait for fill
await new Promise(r => setTimeout(r, 2000))

const detail = await broker.getOrder(placed.orderId)
console.log(` getOrder(${placed.orderId}):`, detail ? JSON.stringify({
symbol: detail.contract.symbol,
action: detail.order.action,
qty: detail.order.totalQuantity.toString(),
status: detail.orderState.status,
orderId_number: detail.order.orderId,
}) : 'null')

expect(detail).not.toBeNull()
if (detail) {
expect(detail.orderState.status).toBe('Filled')
// Bug check: order.orderId should NOT be NaN or meaningless
console.log(` order.orderId (IBKR number field): ${detail.order.orderId} — parseInt('${placed.orderId}') = ${parseInt(placed.orderId, 10)}`)
}
}, 15_000)

it('verifies AAPL position exists after buy', async () => {
if (!broker) return
const positions = await broker.getPositions()
const aapl = positions.find(p => p.contract.symbol === 'AAPL')
expect(aapl).toBeDefined()
if (aapl) {
console.log(` AAPL position: ${aapl.quantity} ${aapl.side}, avg=$${aapl.avgCost}, mkt=$${aapl.marketPrice}, unrealPnL=$${aapl.unrealizedPnL}`)
expect(aapl.quantity.toNumber()).toBeGreaterThan(0)
expect(aapl.avgCost).toBeGreaterThan(0)
expect(aapl.marketPrice).toBeGreaterThan(0)
}
})

it('closes AAPL position', async () => {
if (!broker) return

const contract = new Contract()
contract.aliceId = 'alpaca-AAPL'
contract.symbol = 'AAPL'

// Close all AAPL — use native full close
const result = await broker.closePosition(contract)
console.log(` closePosition: success=${result.success}, orderId=${result.orderId}, error=${result.error}`)
expect(result.success).toBe(true)
}, 15_000)

it('getOrders with known IDs', async () => {
if (!broker) return

// Place + wait + query
const contract = new Contract()
contract.aliceId = 'alpaca-AAPL'
contract.symbol = 'AAPL'
contract.secType = 'STK'

const order = new Order()
order.action = 'BUY'
order.orderType = 'MKT'
order.totalQuantity = new Decimal('1')
order.tif = 'DAY'

const placed = await broker.placeOrder(contract, order)
if (!placed.orderId) return

await new Promise(r => setTimeout(r, 2000))

const orders = await broker.getOrders([placed.orderId])
console.log(` getOrders([${placed.orderId}]): ${orders.length} results`)
expect(orders.length).toBe(1)
if (orders[0]) {
console.log(` order: ${orders[0].contract.symbol} ${orders[0].order.action} ${orders[0].orderState.status}`)
}

// Clean up
await broker.closePosition(contract)
}, 15_000)

it('fetches positions with correct types', async () => {
if (!broker) return
const positions = await broker.getPositions()
console.log(` ${positions.length} positions total`)
for (const p of positions) {
console.log(` ${p.contract.symbol}: qty=${p.quantity} (type=${typeof p.quantity.toNumber()}), avg=${p.avgCost} (type=${typeof p.avgCost}), mkt=${p.marketPrice}`)
// Verify quantity is actually a Decimal
expect(p.quantity).toBeInstanceOf(Decimal)
expect(typeof p.avgCost).toBe('number')
expect(typeof p.marketPrice).toBe('number')
expect(typeof p.unrealizedPnL).toBe('number')
}
})
})
Loading
Loading