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
12 changes: 12 additions & 0 deletions services/uta/src/domain/trading/UnifiedTradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOr
const REACH_RANK: Record<UTAReach, number> = { down: 0, connected: 1, readable: 2 }
import { TradingGit } from './git/TradingGit.js'
import { recomputeCostBasisFromCommits } from './cost-basis.js'
import { projectOrderHistory, projectTradeHistory } from './order-history.js'
import type { OrderHistoryEntry, TradeHistoryEntry } from '@traderalice/uta-protocol'
import { pnlOf } from './position-math.js'
import type {
Operation,
Expand Down Expand Up @@ -628,6 +630,16 @@ export class UnifiedTradingAccount {
return this.git.getPendingOrderIds()
}

/** Exchange-frontend projection — same translation the UI and routes use. */
async orderHistory(limit = 50): Promise<OrderHistoryEntry[]> {
return projectOrderHistory(this.git.exportState().commits, { limit })
}

/** Exchange-frontend projection — fills only. */
async tradeHistory(limit = 50): Promise<TradeHistoryEntry[]> {
return projectTradeHistory(this.git.exportState().commits, { limit })
}

/** firstSeen/lastPolled per pending order — drives the per-order polling
* backoff for brokers without a listing API. In-memory only: a restart
* resets every order to "fresh", which just means one eager poll. */
Expand Down
34 changes: 31 additions & 3 deletions services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,29 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
return { success: false, error: 'Either totalQuantity or cashQty must be provided' }
}

// Attached TP/SL on CCXT venues: REFUSE until a per-exchange override
// has verified the attach actually reaches the venue. Observed live on
// okx spot: the unified takeProfit/stopLoss params were accepted by
// ccxt, silently dropped at the venue mapping, and the entry filled
// UNPROTECTED — the ledger said "long with a stop", the exchange said
// "naked long". A missing stop that looks attached is the worst failure
// mode a trading system has; loud refusal beats silent downgrade
// (same rule as order-type support). Venue-verified attach
// implementations land via the overrides registry (fetchAllOpenOrders
// pattern) — okx needs attachAlgoOrds, bybit its native v5 fields.
if (tpsl?.takeProfit || tpsl?.stopLoss) {
const attachOverride = this.overrides.placeOrderWithTpSl
if (!attachOverride) {
return {
success: false,
error:
`Attached TP/SL is not verified to reach ${this.exchangeName} through ccxt — refusing rather than ` +
`risking a silently unprotected position. Place the entry first, then a separate stop/take-profit ` +
`order, or use a venue with verified attach support.`,
}
}
}

try {
const params: Record<string, unknown> = { ...extraParams }

Expand All @@ -460,10 +483,15 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
? order.lmtPrice.toNumber()
: undefined

const attachOverride = this.overrides.placeOrderWithTpSl
const placeOverride = this.overrides.placeOrder
const ccxtOrder = placeOverride
? await placeOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params, defaultPlaceOrder)
: await defaultPlaceOrder(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params)
const ccxtOrder = (tpsl?.takeProfit || tpsl?.stopLoss) && attachOverride
// Venue-verified attach path — the gate above guarantees tpsl only
// gets this far when the exchange has an override for it.
? await attachOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, tpsl, params)
: placeOverride
? await placeOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params, defaultPlaceOrder)
: await defaultPlaceOrder(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params)

// Cache orderId → symbol
if (ccxtOrder.id) {
Expand Down
18 changes: 18 additions & 0 deletions services/uta/src/domain/trading/brokers/ccxt/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ export interface CcxtExchangeOverrides {
defaultImpl: DefaultImpl<[Exchange], CcxtPosition[]>,
): Promise<CcxtPosition[]>

/** Place an order WITH attached TP/SL, venue-verified. CcxtBroker
* refuses tpsl placement entirely when an exchange has no such
* override — observed live: ccxt's unified takeProfit/stopLoss params
* were silently dropped on okx spot and the entry filled unprotected.
* Implementations must map to the venue's real attach mechanism (okx:
* attachAlgoOrds; bybit: v5 takeProfit/stopLoss fields) and be verified
* live before registering. */
placeOrderWithTpSl?(
exchange: Exchange,
symbol: string,
type: string,
side: 'buy' | 'sell',
amount: number,
price: number | undefined,
tpsl: { takeProfit?: { price: string }; stopLoss?: { price: string; limitPrice?: string } },
params: Record<string, unknown>,
): Promise<CcxtOrder>

/** List ALL open orders across every market type the account trades.
* Override when the venue's listing endpoint is category-scoped and the
* unscoped call silently returns a subset (bybit: defaultType 'swap'
Expand Down
2 changes: 1 addition & 1 deletion services/uta/src/domain/trading/git/TradingGit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ export class TradingGit implements ITradingGit {
const commit: GitCommit = {
hash,
parentHash: this.head,
message: `[sync] ${updates.length} order(s) updated`,
message: `[sync] ${updates.slice(0, 3).map((u) => `${u.symbol} ${u.currentStatus}`).join(', ')}${updates.length > 3 ? ` +${updates.length - 3} more` : ''}`,
operations: [{ action: 'syncOrders' as const }],
results: updates.map((u) => ({
action: 'syncOrders' as const,
Expand Down
4 changes: 4 additions & 0 deletions src/server/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,14 @@ export const CLI_EXPORTS: Record<string, CliExport> = {
},
order: {
list: 'getOrders',
history: 'orderHistory',
trades: 'tradeHistory',
place: 'placeOrder',
modify: 'modifyOrder',
cancel: 'cancelOrder',
},
position: {
// listing positions = `account portfolio` (one tool, one verb).
close: 'closePosition',
},
// trading-as-git: the approval/state flow mirrors git verbs on purpose.
Expand All @@ -180,6 +183,7 @@ export const CLI_EXPORTS: Record<string, CliExport> = {
show: 'tradingShow',
commit: 'tradingCommit',
push: 'tradingPush',
reject: 'tradingReject',
sync: 'tradingSync',
},
market: {
Expand Down
15 changes: 13 additions & 2 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void {
if (!tool) continue
let schema: unknown = {}
try {
schema = z.toJSONSchema(tool.inputSchema as z.ZodType)
// io:'input' + unrepresentable:'any' — schemas with .transform()
// (e.g. trading's positiveNumeric) have no output-side JSON-schema
// representation; the default call threw and the catch silently
// rendered "(no flags)" for every order verb, leaving agents to
// guess flag names from prose. Input-side conversion is exactly
// what a CLI manifest wants anyway.
schema = z.toJSONSchema(tool.inputSchema as z.ZodType, { io: 'input', unrepresentable: 'any' })
} catch {
/* leave {} */
}
Expand Down Expand Up @@ -161,7 +167,12 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void {
try {
validated = await schema.parseAsync(rawArgs)
} catch (err) {
return c.json({ error: 'Validation failed', details: String(err) }, 400)
// Field-level issues, not String(ZodError) — an agent reading
// "Validation failed" alone is stranded guessing flag names/shapes.
const details = err instanceof z.ZodError
? err.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('\n')
: String(err)
return c.json({ error: 'Validation failed', details }, 400)
}

const result = await wrapToolExecute(tool)(validated)
Expand Down
33 changes: 28 additions & 5 deletions src/services/uta-client/UTAAccountSDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import type {
UTAClient,
AccountInfo,
OrderHistoryEntry,
TradeHistoryEntry,
Position,
OpenOrder,
Quote,
Expand Down Expand Up @@ -166,13 +168,18 @@ export class UTAAccountSDK {
}

searchContracts(pattern: string): Promise<ContractDescription[]> {
// The existing `/api/trading/contracts/search` is aggregated across
// accounts; per-account search isn't a route yet. Fall back to the
// aggregated endpoint and filter by id. Route added in Step 6 follow-up.
// The `/api/trading/contracts/search` route is aggregated across
// accounts and returns FLAT rows `{ source, contract, ... }` — one per
// hit, tagged with the owning account. (An earlier SDK version assumed
// a grouped `{ id, results[] }` shape; the find() never matched and
// every per-account search silently returned [] — an analysis-killing
// false negative: "SOL isn't tradeable" when it plainly was.)
return this.client
.get<{ results: Array<{ id: string; results: ContractDescription[] }> }>(
.get<{ results: Array<{ source: string } & ContractDescription> }>(
`/api/trading/contracts/search`, { pattern })
.then((r) => r.results.find((b) => b.id === this.id)?.results ?? [])
.then((r) => r.results
.filter((row) => row.source === this.id)
.map(({ source: _source, ...desc }) => desc as ContractDescription))
}

// ==================== Contract details ====================
Expand Down Expand Up @@ -209,6 +216,22 @@ export class UTAAccountSDK {
return this.client.get<GitStatus>(`/api/trading/uta/${encodeURIComponent(this.id)}/wallet/status`)
}

/** Exchange-frontend projection: one row per order, lifecycle collapsed. */
async orderHistory(limit = 50): Promise<OrderHistoryEntry[]> {
const r = await this.client.get<{ orders: OrderHistoryEntry[] }>(
`/api/trading/uta/${encodeURIComponent(this.id)}/order-history?limit=${limit}`,
)
return r.orders
}

/** Exchange-frontend projection: fills only (reconcile foldings labeled). */
async tradeHistory(limit = 50): Promise<TradeHistoryEntry[]> {
const r = await this.client.get<{ trades: TradeHistoryEntry[] }>(
`/api/trading/uta/${encodeURIComponent(this.id)}/trade-history?limit=${limit}`,
)
return r.trades
}

getState(): Promise<GitState> {
// Wallet status returns GitStatus (a projection of GitState); for now
// synthesize a minimal GitState shape from status. Route gap tracked.
Expand Down
121 changes: 121 additions & 0 deletions src/tool/trading-compact.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest'
import Decimal from 'decimal.js'
import {
val, money, price,
compactContract, compactOrderFields, compactOperation,
compactResult, compactStatus, compactAccountInfo,
} from './trading-compact.js'

describe('val — sentinel normalization', () => {
it('drops every IBKR unset sentinel in every value form', () => {
expect(val(1.7976931348623157e+308)).toBeUndefined() // UNSET_DOUBLE number
expect(val('1.7976931348623157e+308')).toBeUndefined() // …as string
expect(val(2147483647)).toBeUndefined() // UNSET_INTEGER
expect(val('1.70141183460469231731687303715884105727e+38')).toBeUndefined() // UNSET_DECIMAL string
expect(val(new Decimal('1.70141183460469231731687303715884105727e+38'))).toBeUndefined()
expect(val('')).toBeUndefined()
expect(val(null)).toBeUndefined()
expect(val(undefined)).toBeUndefined()
})

it('keeps real values, including awkward ones', () => {
expect(val('0.01')).toBe('0.01')
expect(val(0)).toBe('0')
expect(val(new Decimal('0.00000001'))).toBe('0.00000001')
expect(val('DAY')).toBe('DAY')
})
})

describe('money / price — display precision', () => {
it('caps money at 2dp and price at 8dp without mangling', () => {
expect(money('90273.826752780986')).toBe('90273.83')
expect(money('150.48841053856901168')).toBe('150.49')
expect(price('2165.8896354239932')).toBe('2165.88963542')
expect(price('0.00000001')).toBe('0.00000001')
})
})

describe('compactContract', () => {
it('keeps instrument identity, drops sentinels/empties, normalizes right', () => {
const c = compactContract({
conId: 0, symbol: 'AAPL', secType: 'OPT', lastTradeDateOrContractMonth: '20260717',
lastTradeDate: '', strike: 300, right: 'CALL', multiplier: '100', exchange: 'SMART',
primaryExchange: '', currency: 'USD', localSymbol: 'AAPL 260717C00300000',
tradingClass: '', includeExpired: false, secIdType: '', comboLegs: [],
deltaNeutralContract: null, aliceId: 'ibkr|x',
})
expect(c).toEqual({
aliceId: 'ibkr|x', symbol: 'AAPL', localSymbol: 'AAPL 260717C00300000',
secType: 'OPT', currency: 'USD', exchange: 'SMART',
expiry: '20260717', strike: '300', right: 'C', multiplier: '100',
})
})

it('omits multiplier when it is 1 (canonical, carries no signal)', () => {
expect(compactContract({ symbol: 'ETH', multiplier: '1' })).toEqual({ symbol: 'ETH' })
})
})

describe('compactOrderFields', () => {
it('reduces the ~120-field Order to its set fields', () => {
const o = compactOrderFields({
action: 'BUY', orderType: 'LMT', totalQuantity: '0.01', lmtPrice: '1200',
auxPrice: '1.70141183460469231731687303715884105727e+38',
trailingPercent: '1.70141183460469231731687303715884105727e+38',
minQty: 2147483647, percentOffset: 1.7976931348623157e+308,
tif: 'DAY', outsideRth: false, softDollarTier: { name: '' },
filledQuantity: '1.70141183460469231731687303715884105727e+38',
})
expect(o).toEqual({ action: 'BUY', orderType: 'LMT', totalQuantity: '0.01', lmtPrice: '1200', tif: 'DAY' })
})
})

describe('compactOperation / compactStatus / compactResult', () => {
it('placeOrder operation has no sentinel anywhere in its JSON', () => {
const op = compactOperation({
action: 'placeOrder',
contract: { symbol: 'ETH', strike: 1.7976931348623157e+308, conId: 0 },
order: { action: 'BUY', totalQuantity: '0.01', minQty: 2147483647 },
})
const json = JSON.stringify(op)
expect(json).not.toMatch(/1\.797693|1\.7014118|2147483647/)
expect(op).toEqual({ action: 'placeOrder', contract: { symbol: 'ETH' }, order: { action: 'BUY', totalQuantity: '0.01' } })
})

it('compactResult drops raw + orderState but keeps the reject reason', () => {
const r = compactResult({
action: 'placeOrder', success: false, status: 'rejected', error: 'price band',
raw: { huge: 'payload' },
orderState: { status: 'Inactive', rejectReason: 'okx 51138', commissionAndFees: 1.7976931348623157e+308 },
})
expect(r).toEqual({ action: 'placeOrder', success: false, status: 'rejected', error: 'price band', rejectReason: 'okx 51138' })
})

it('compactStatus compacts staged ops and renames pending→awaitingApproval', () => {
const idle = compactStatus({
staged: [{ action: 'cancelOrder', orderId: 'o1' }],
pendingMessage: null, pendingHash: null, head: 'abc', commitCount: 5,
})
expect(idle).toEqual({ staged: [{ action: 'cancelOrder', orderId: 'o1' }], awaitingApproval: null, head: 'abc', commitCount: 5 })

const committed = compactStatus({
staged: [], pendingMessage: 'long ETH', pendingHash: 'h1', head: 'abc', commitCount: 5,
})
expect(committed.awaitingApproval).toEqual({ message: 'long ETH', hash: 'h1' })
})
})

describe('compactAccountInfo', () => {
it('rounds money to 2dp and omits unreported fields (never fabricates zeros)', () => {
const a = compactAccountInfo({
baseCurrency: 'USD', netLiquidation: '90273.826752780986',
totalCashValue: '81351.50743564543', unrealizedPnL: '150.48841053856901168',
realizedPnL: '-0.3654613868044494', initMarginReq: '1.11583333333',
})
expect(a).toEqual({
baseCurrency: 'USD', netLiquidation: '90273.83', totalCashValue: '81351.51',
unrealizedPnL: '150.49', realizedPnL: '-0.37', initMarginReq: '1.12',
})
expect('buyingPower' in a).toBe(false)
})
})
Loading
Loading