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
76 changes: 76 additions & 0 deletions packages/uta-protocol/src/types/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Trader-facing projections of the Trading-as-Git commit log.
*
* The git log is the faithful narrative (operations + results + sync
* updates); these are the exchange-frontend views of it: Order History
* (every order's lifecycle collapsed to one row) and Trade History (fills
* only). Projection lives in the UTA domain so every surface — UI, MCP
* tools, CLI — reads the same translation.
*
* Contract fields follow the IBKR superset deliberately: options/futures
* (strike / right / expiry / multiplier) must render correctly the day
* they arrive, not get retrofitted around a crypto-shaped subset.
*/

/** Compact contract identity for history rows — IBKR-superset fields. */
export interface HistoryContract {
aliceId?: string
symbol?: string
localSymbol?: string
secType?: string
currency?: string
exchange?: string
/** OPT/FOP/FUT: contract month or expiry (IBKR lastTradeDateOrContractMonth). */
expiry?: string
/** OPT/FOP: strike price (string — Decimal-safe). */
strike?: string
/** OPT/FOP: 'C' | 'P' (normalized). */
right?: string
multiplier?: string
}

export type OrderHistoryStatus = 'submitted' | 'filled' | 'cancelled' | 'rejected' | 'user-rejected'

export type OrderHistorySource = 'alice' | 'external'

export interface OrderHistoryEntry {
/** Broker order id (absent for rejected-before-submit). */
orderId?: string
/** When the order entered the log (push/observe time, ISO). */
timestamp: string
/** When the terminal transition was recorded, if any (sync/cancel time, ISO). */
resolvedAt?: string
contract: HistoryContract
side: 'BUY' | 'SELL'
orderType?: string
quantity?: string
limitPrice?: string
stopPrice?: string
status: OrderHistoryStatus
filledQty?: string
avgFillPrice?: string
/** 'external' = observed on the broker, not placed through Alice. */
source: OrderHistorySource
/** Commit that introduced the order — the audit pointer. */
commitHash: string
/** Commit message (user intent for Alice orders; [observed] for external). */
message: string
error?: string
}

export type TradeHistorySource = 'order' | 'external' | 'reconcile'

export interface TradeHistoryEntry {
/** Fill record time (ISO) — push time for immediate fills, sync time otherwise. */
timestamp: string
orderId?: string
contract: HistoryContract
side: 'BUY' | 'SELL'
quantity: string
price: string
/** quantity × price × multiplier (string — Decimal-safe). */
value: string
/** 'reconcile' = balance drift folded in at observed price, not a real fill record. */
source: TradeHistorySource
commitHash: string
}
1 change: 1 addition & 0 deletions packages/uta-protocol/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * from './manager.js'
// `aliceId?` field. Re-export as side-effect so consumers don't need a
// separate import for the augmentation.
import './contract-ext.js'
export * from './history.js'
168 changes: 168 additions & 0 deletions services/uta/src/domain/trading/order-history.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest'
import Decimal from 'decimal.js'
import { Contract, Order } from '@traderalice/ibkr'
import { projectOrderHistory, projectTradeHistory } from './order-history.js'
import type { GitCommit, Operation, OperationResult } from './git/types.js'
import './contract-ext.js'

let n = 0
function commit(ops: Operation[], results: OperationResult[], message = 'test'): GitCommit {
return {
hash: `c${++n}`,
parentHash: null,
message,
operations: ops,
results,
stateAfter: {
netLiquidation: '0', totalCashValue: '0', unrealizedPnL: '0', realizedPnL: '0',
positions: [], pendingOrders: [],
},
timestamp: new Date(1_700_000_000_000 + n * 60_000).toISOString(),
}
}

function contract(over: Partial<Contract> = {}): Contract {
const c = new Contract()
c.aliceId = 'okx|ETH/USDT'
c.symbol = 'ETH'
c.localSymbol = 'ETH/USDT'
c.secType = 'CRYPTO'
Object.assign(c, over)
return c
}

function limitBuy(qty: string, price: string): Order {
const o = new Order()
o.action = 'BUY'
o.orderType = 'LMT'
o.totalQuantity = new Decimal(qty)
o.lmtPrice = new Decimal(price)
return o
}

describe('projectOrderHistory', () => {
it('collapses place→sync-fill into one resolved row', () => {
const commits = [
commit(
[{ action: 'placeOrder', contract: contract(), order: limitBuy('0.01', '1650') }],
[{ action: 'placeOrder', success: true, orderId: 'o1', status: 'submitted' }],
'buy the dip',
),
commit(
[{ action: 'syncOrders' }],
[{ action: 'syncOrders', success: true, orderId: 'o1', status: 'filled', filledQty: '0.01', filledPrice: '1648.5' }],
),
]
const rows = projectOrderHistory(commits)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
orderId: 'o1',
side: 'BUY',
orderType: 'LMT',
quantity: '0.01',
limitPrice: '1650',
status: 'filled',
filledQty: '0.01',
avgFillPrice: '1648.5',
source: 'alice',
message: 'buy the dip',
})
expect(rows[0].resolvedAt).toBeDefined()
})

it('external observed orders are flagged and resolvable by cancel', () => {
const commits = [
commit(
[{ action: 'observeExternalOrder', contract: contract(), order: limitBuy('1', '1500') }],
[{ action: 'observeExternalOrder', success: true, orderId: 'ext1', status: 'submitted' }],
'[observed] 1 external order(s)',
),
commit(
[{ action: 'cancelOrder', orderId: 'ext1' }],
[{ action: 'cancelOrder', success: true, orderId: 'ext1', status: 'cancelled' }],
),
]
const rows = projectOrderHistory(commits)
expect(rows).toHaveLength(1)
expect(rows[0].source).toBe('external')
expect(rows[0].status).toBe('cancelled')
})

it('preserves IBKR-superset option fields on the contract', () => {
const opt = contract({
aliceId: 'ibkr|AAPL 260717C300', symbol: 'AAPL', secType: 'OPT',
lastTradeDateOrContractMonth: '20260717', strike: 300, right: 'C', multiplier: '100',
})
const commits = [
commit(
[{ action: 'placeOrder', contract: opt, order: limitBuy('1', '12.5') }],
[{ action: 'placeOrder', success: true, orderId: 'opt1', status: 'submitted' }],
),
]
const row = projectOrderHistory(commits)[0]
expect(row.contract).toMatchObject({
secType: 'OPT', expiry: '20260717', strike: '300', right: 'C', multiplier: '100',
})
})

it('rejected-before-submit rows survive without an orderId', () => {
const commits = [
commit(
[{ action: 'placeOrder', contract: contract(), order: limitBuy('0.01', '99999') }],
[{ action: 'placeOrder', success: false, status: 'rejected', error: 'price band' }],
),
]
const rows = projectOrderHistory(commits)
expect(rows).toHaveLength(1)
expect(rows[0].status).toBe('rejected')
expect(rows[0].error).toBe('price band')
})
})

describe('projectTradeHistory', () => {
it('records sync fills once with side/contract joined from the origin', () => {
const commits = [
commit(
[{ action: 'placeOrder', contract: contract(), order: limitBuy('0.01', '1650') }],
[{ action: 'placeOrder', success: true, orderId: 'o1', status: 'submitted' }],
),
commit(
[{ action: 'syncOrders' }],
[{ action: 'syncOrders', success: true, orderId: 'o1', status: 'filled', filledQty: '0.01', filledPrice: '1648.5' }],
),
]
const trades = projectTradeHistory(commits)
expect(trades).toHaveLength(1)
expect(trades[0]).toMatchObject({
orderId: 'o1', side: 'BUY', quantity: '0.01', price: '1648.5', value: '16.485', source: 'order',
})
})

it('reconcile foldings are labeled, not disguised as fills', () => {
const commits = [
commit(
[{ action: 'reconcileBalance', aliceId: 'okx|ETH/USDT', quantityDelta: '0.5', markPrice: '1700' }],
[{ action: 'reconcileBalance', success: true, status: 'filled', filledQty: '0.5', filledPrice: '1700' }],
),
]
const trades = projectTradeHistory(commits)
expect(trades).toHaveLength(1)
expect(trades[0].source).toBe('reconcile')
expect(trades[0].side).toBe('BUY')
})

it('does not double-count an origin fill against a redundant sync', () => {
const order = limitBuy('1', '100')
const commits = [
commit(
[{ action: 'placeOrder', contract: contract(), order }],
[{ action: 'placeOrder', success: true, orderId: 'o2', status: 'filled', filledQty: '1', filledPrice: '100' }],
),
commit(
[{ action: 'syncOrders' }],
[{ action: 'syncOrders', success: true, orderId: 'o2', status: 'filled', filledQty: '1', filledPrice: '100' }],
),
]
expect(projectTradeHistory(commits)).toHaveLength(1)
})
})
Loading
Loading