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
182 changes: 181 additions & 1 deletion src/domain/trading/UnifiedTradingAccount.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import Decimal from 'decimal.js'
import { Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr'
import { UnifiedTradingAccount } from './UnifiedTradingAccount.js'
Expand Down Expand Up @@ -518,3 +518,183 @@ describe('UTA — constructor', () => {
expect(restored.log()[0].message).toBe('initial buy')
})
})

// ==================== health tracking ====================

describe('UTA — health tracking', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })

/** Let _connect() (fire-and-forget from constructor) complete via microtask flush. */
async function flush() { await vi.advanceTimersByTimeAsync(0) }

it('connects automatically on construction and becomes healthy', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()

expect(uta.health).toBe('healthy')
expect(uta.getHealthInfo().lastSuccessAt).toBeInstanceOf(Date)
})

it('goes offline and starts recovery when initial connect fails', async () => {
const broker = new MockBroker()
broker.setFailMode(100) // init + getAccount will fail
const { uta } = createUTA(broker)
await flush()

expect(uta.health).toBe('offline')
expect(uta.getHealthInfo().recovering).toBe(true)
await uta.close()
})

it('auto-recovers after initial connect failure when broker comes back', async () => {
const broker = new MockBroker()
// _connect calls init() which fails (consumes 1). Recovery at 5s: init() + getAccount() succeed.
broker.setFailMode(1)
const { uta } = createUTA(broker)
await flush()

expect(uta.health).toBe('offline')

// Advance to trigger first recovery attempt — broker is back (failMode exhausted)
await vi.advanceTimersByTimeAsync(5_000)

expect(uta.health).toBe('healthy')
expect(uta.getHealthInfo().recovering).toBe(false)
})

it('transitions healthy → degraded after 3 consecutive failures', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
broker.setFailMode(3)

for (let i = 0; i < 3; i++) {
await expect(uta.getAccount()).rejects.toThrow()
}
expect(uta.health).toBe('degraded')
})

it('transitions degraded → offline after 6 consecutive failures', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
broker.setFailMode(6)

for (let i = 0; i < 6; i++) {
await expect(uta.getAccount()).rejects.toThrow()
}
expect(uta.health).toBe('offline')
await uta.close()
})

it('resets to healthy on any successful call', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
broker.setFailMode(4)

for (let i = 0; i < 4; i++) {
await expect(uta.getAccount()).rejects.toThrow()
}
expect(uta.health).toBe('degraded')

// Next call succeeds (failMode exhausted)
await uta.getAccount()
expect(uta.health).toBe('healthy')
expect(uta.getHealthInfo().consecutiveFailures).toBe(0)
})

it('fails fast when offline and recovering', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
broker.setFailMode(100)

for (let i = 0; i < 6; i++) {
await expect(uta.getAccount()).rejects.toThrow()
}
expect(uta.health).toBe('offline')
expect(uta.getHealthInfo().recovering).toBe(true)

// Subsequent calls fail fast with offline message
await expect(uta.getAccount()).rejects.toThrow(/offline and reconnecting/)
await uta.close()
})

it('push() throws when offline', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
broker.setFailMode(100)

for (let i = 0; i < 6; i++) {
await expect(uta.getAccount()).rejects.toThrow()
}

uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 })
uta.commit('buy AAPL')
await expect(uta.push()).rejects.toThrow(/offline/)
await uta.close()
})

it('auto-recovery restores healthy after runtime disconnect', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
expect(uta.health).toBe('healthy')

// Go offline via runtime failures
broker.setFailMode(6)
for (let i = 0; i < 6; i++) {
await expect(uta.getAccount()).rejects.toThrow()
}
expect(uta.health).toBe('offline')
expect(uta.getHealthInfo().recovering).toBe(true)

// Broker is back (failMode exhausted) — advance timer to trigger recovery
await vi.advanceTimersByTimeAsync(5_000)

expect(uta.health).toBe('healthy')
expect(uta.getHealthInfo().recovering).toBe(false)
})

it('close() cancels recovery timer', async () => {
const broker = new MockBroker()
broker.setFailMode(100)
const { uta } = createUTA(broker)
await flush()

expect(uta.getHealthInfo().recovering).toBe(true)
await uta.close()
expect(uta.getHealthInfo().recovering).toBe(false)
})

it('getHealthInfo returns full snapshot', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()

const info = uta.getHealthInfo()
expect(info.status).toBe('healthy')
expect(info.consecutiveFailures).toBe(0)
expect(info.lastSuccessAt).toBeInstanceOf(Date)
expect(info.recovering).toBe(false)
})

it('tracks health across different broker methods', async () => {
const broker = new MockBroker()
const { uta } = createUTA(broker)
await flush()
broker.setFailMode(2)

await expect(uta.getAccount()).rejects.toThrow()
await expect(uta.getPositions()).rejects.toThrow()
expect(uta.getHealthInfo().consecutiveFailures).toBe(2)

// Success on a different method resets
await uta.getMarketClock()
expect(uta.health).toBe('healthy')
})
})
Loading
Loading