-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathIbkrBroker.spec.ts
More file actions
237 lines (209 loc) · 9.81 KB
/
Copy pathIbkrBroker.spec.ts
File metadata and controls
237 lines (209 loc) · 9.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
import { describe, it, expect } from 'vitest'
import Decimal from 'decimal.js'
import { Contract, Order } from '@traderalice/ibkr'
import { IbkrBroker } from './IbkrBroker.js'
/**
* The gate must fire BEFORE any bridge/client access, so it is testable on
* a bare prototype instance — no TWS connection, no bridge construction.
*/
function bareBroker(): IbkrBroker {
return Object.create(IbkrBroker.prototype) as IbkrBroker
}
function stkOrder(): { contract: Contract; order: Order } {
const contract = new Contract()
contract.symbol = 'AAPL'
contract.secType = 'STK'
contract.exchange = 'SMART'
contract.currency = 'USD'
const order = new Order()
order.action = 'BUY'
order.orderType = 'LMT'
order.totalQuantity = new Decimal(1)
order.lmtPrice = new Decimal(100)
return { contract, order }
}
describe('IbkrBroker — attached TP/SL refusal gate', () => {
// Guards the silent naked-entry failure: the tpsl param used to be
// `_tpsl` (ignored) — the ledger recorded protection TWS never received.
it('refuses placeOrder with takeProfit', async () => {
const { contract, order } = stkOrder()
const result = await bareBroker().placeOrder(contract, order, { takeProfit: { price: '120' } })
expect(result.success).toBe(false)
expect(result.error).toMatch(/TP\/SL.*not implemented|refusing/i)
})
it('refuses placeOrder with stopLoss', async () => {
const { contract, order } = stkOrder()
const result = await bareBroker().placeOrder(contract, order, { stopLoss: { price: '90' } })
expect(result.success).toBe(false)
expect(result.error).toMatch(/refusing/i)
})
it('an empty tpsl object does not trip the gate', async () => {
const { contract, order } = stkOrder()
// No bridge on the bare instance — passing the gate means it throws on
// bridge access, NOT a refusal result.
await expect(async () => {
const r = await bareBroker().placeOrder(contract, order, {})
if (r.success === false && /refusing/i.test(r.error ?? '')) throw new Error('gate tripped')
return r
}).not.toThrow(/gate tripped/)
})
})
describe('IbkrBroker — nativeKey grammar (hub/leaf identity)', () => {
// conId = canonical leaf; issuer: = bond-issuer directory; bare symbol =
// STK convenience. Hubs must REFUSE resolution (directories aren't
// tradeable) instead of the old silent assume-STK.
it('getNativeKey prefers conId, falls back to issuer: for bond hubs, then symbol', () => {
const b = bareBroker()
const leaf = new Contract()
leaf.conId = 265598
leaf.symbol = 'AAPL'
expect(b.getNativeKey(leaf)).toBe('265598')
const bondHub = new Contract()
bondHub.secType = 'BOND'
bondHub.issuerId = 'e1400789'
expect(b.getNativeKey(bondHub)).toBe('issuer:e1400789')
const symbolOnly = new Contract()
symbolOnly.symbol = 'AAPL'
expect(b.getNativeKey(symbolOnly)).toBe('AAPL')
})
it('resolveNativeKey refuses issuer: directories with an actionable message', () => {
const b = bareBroker()
expect(() => b.resolveNativeKey('issuer:e1400789')).toThrow(/directory.*expand|expand.*directory/i)
})
it('resolveNativeKey round-trips conId and keeps the STK symbol convenience', () => {
const b = bareBroker()
expect(b.resolveNativeKey('265598').conId).toBe(265598)
const sym = b.resolveNativeKey('AAPL')
expect(sym.symbol).toBe('AAPL')
expect(sym.secType).toBe('STK')
})
})
describe('IbkrBroker — getAccount mixed-currency math (ANG-101 / issues #295 #314)', () => {
function brokerWithCache(values: Record<string, string>, positions: unknown[]): IbkrBroker {
const b = bareBroker()
;(b as unknown as { bridge: unknown }).bridge = {
getAccountCache: () => ({ values: new Map(Object.entries(values)), positions }),
}
return b
}
const hkdPos = { contract: { conId: 1 }, currency: 'HKD', unrealizedPnL: '-4767.62', marketValue: '46426.72' }
const usdPos = { contract: { conId: 2 }, currency: 'USD', unrealizedPnL: '368.80', marketValue: '2913.10' }
it('converts per-position PnL via TWS ExchangeRate tags instead of blind-summing', async () => {
const b = brokerWithCache({
TotalCashValue: '1036370.91', NetLiquidation: '1046101.70',
'ExchangeRate:HKD': '0.1276211',
RealizedPnL: '0', BuyingPower: '0', InitMarginReq: '0', MaintMarginReq: '0',
}, [hkdPos, usdPos])
const a = await b.getAccount()
// -4767.62 × 0.1276211 + 368.80 = -239.66… (blind sum was -4398.82)
expect(Number(a.unrealizedPnL)).toBeCloseTo(-239.66, 1)
// Mixed book → TWS's consolidated NetLiquidation tag wins (#314)
expect(a.netLiquidation).toBe('1046101.7')
})
it('missing FX rate falls back to broker values, never sums garbage', async () => {
const b = brokerWithCache({
TotalCashValue: '1036370.91', NetLiquidation: '1046101.70', UnrealizedPnL: '-240',
RealizedPnL: '0', BuyingPower: '0', InitMarginReq: '0', MaintMarginReq: '0',
}, [hkdPos, usdPos])
const a = await b.getAccount()
expect(a.unrealizedPnL).toBe('-240')
expect(a.netLiquidation).toBe('1046101.7')
})
it('same-currency book keeps the reconstructed (fresher) netLiquidation', async () => {
const b = brokerWithCache({
TotalCashValue: '1000', NetLiquidation: '99999',
RealizedPnL: '0', BuyingPower: '0', InitMarginReq: '0', MaintMarginReq: '0',
}, [{ ...usdPos, multiplier: '1', quantity: '10' }])
const a = await b.getAccount()
expect(a.netLiquidation).not.toBe('99999') // cash + Σ marketValue, not the cached tag
})
})
describe('IbkrBroker — placeOrder resolves the real contract before routing (forex/CASH)', () => {
// The bug: placeOrder defaulted a bare { conId, symbol } to STK SMART/USD,
// so forex (e.g. USDCHF, conId 12087820) reached IBKR as the wrong contract
// and was rejected with "error 200: No security definition". The fix reuses
// the same resolver the quote/details path uses (resolveFullContract), which
// round-trips the conId to its real CASH/IDEALPRO identity.
/** What reqContractDetails returns for the forex conId. */
function usdChfContract(): Contract {
const c = new Contract()
c.conId = 12087820
c.symbol = 'USD'
c.secType = 'CASH'
c.currency = 'CHF'
c.exchange = 'IDEALPRO'
c.localSymbol = 'USD.CHF'
return c
}
/** Wire up a bare instance: alive bridge, captured client.placeOrder, and a
* getContractDetails stub that records whether it was called. */
function orderBroker(detailsContract: Contract | null): {
broker: IbkrBroker
captured: { contract?: Contract }
detailsCalls: () => number
} {
const b = bareBroker()
;(b as unknown as { conIdContracts: Map<number, Contract> }).conIdContracts = new Map()
const captured: { contract?: Contract } = {}
let calls = 0
;(b as unknown as { getContractDetails: (q: Contract) => Promise<unknown> }).getContractDetails =
async () => { calls++; return detailsContract ? { contract: detailsContract } : null }
;(b as unknown as { bridge: unknown }).bridge = {
connectionDead: false,
getNextOrderId: () => 1,
requestOrder: () => Promise.resolve({ orderState: { status: 'Submitted' } }),
}
;(b as unknown as { client: unknown }).client = {
placeOrder: (_id: number, contract: Contract) => { captured.contract = contract },
}
return { broker: b, captured, detailsCalls: () => calls }
}
it('Case A: a bare { conId, symbol: "USDCHF" } order is sent as CASH/IDEALPRO, matching getContractDetails', async () => {
const resolved = usdChfContract()
const { broker, captured, detailsCalls } = orderBroker(resolved)
const bare = new Contract()
bare.conId = 12087820
bare.symbol = 'USDCHF' // the wrong/loose symbol staging hands us
const order = new Order()
order.action = 'BUY'
order.orderType = 'LMT'
order.totalQuantity = new Decimal(1000)
order.lmtPrice = new Decimal('0.79')
const r = await broker.placeOrder(bare, order)
expect(r.success).toBe(true)
expect(detailsCalls()).toBe(1) // the conId was actually resolved, not defaulted
// The contract handed to TWS is the real forex contract...
expect(captured.contract).toEqual(usdChfContract())
// ...i.e. CASH / USD / CHF / IDEALPRO / USD.CHF, never STK SMART/USD.
expect(captured.contract?.secType).toBe('CASH')
expect(captured.contract?.exchange).toBe('IDEALPRO')
expect(captured.contract?.currency).toBe('CHF')
expect(captured.contract?.localSymbol).toBe('USD.CHF')
})
it('Case B: a fully-typed STK order passes through unchanged, with no getContractDetails round-trip', async () => {
const { broker, captured, detailsCalls } = orderBroker(null)
const { contract, order } = stkOrder()
const r = await broker.placeOrder(contract, order)
expect(r.success).toBe(true)
expect(detailsCalls()).toBe(0) // no resolution needed for an already-typed contract
expect(captured.contract).toBe(contract) // same object, untouched
expect(captured.contract?.symbol).toBe('AAPL')
expect(captured.contract?.secType).toBe('STK')
expect(captured.contract?.exchange).toBe('SMART')
expect(captured.contract?.currency).toBe('USD')
})
})
describe('IbkrBroker — dead-connection gate (issue #294)', () => {
it('cache-backed reads and order paths refuse loudly when the socket is known-dead', async () => {
const b = bareBroker()
;(b as unknown as { bridge: unknown }).bridge = { connectionDead: true }
await expect(b.getAccount()).rejects.toThrow(/connection lost/i)
await expect(b.getPositions()).rejects.toThrow(/connection lost/i)
const { contract, order } = stkOrder()
const r = await b.placeOrder(contract, order)
// placeOrder catches and returns { success: false } — the message must
// still carry the dead-connection cause, not a generic failure.
expect(r.success).toBe(false)
expect(r.error).toMatch(/connection lost/i)
})
})